Skip to content

Commit 6f5eb41

Browse files
authored
Merge pull request #4850 from Textualize/multi-bindings
WIP Multiple bindings
2 parents b2af20c + 9059f13 commit 6f5eb41

File tree

13 files changed

+360
-102
lines changed

13 files changed

+360
-102
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ 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
8+
## [0.76.0]
99

1010
### Changed
1111

@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1818
### Fixed
1919

2020
- Input cursor blink effect will now restart correctly when any action is performed on the input https://github.com/Textualize/textual/pull/4773
21+
- Fixed bindings on same key not updating description https://github.com/Textualize/textual/pull/4850
2122

2223
### Added
2324

@@ -2274,6 +2275,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
22742275
- New handler system for messages that doesn't require inheritance
22752276
- Improved traceback handling
22762277

2278+
[0.76.0]: https://github.com/Textualize/textual/compare/v0.75.1...v0.76.0
22772279
[0.75.1]: https://github.com/Textualize/textual/compare/v0.75.0...v0.75.1
22782280
[0.75.0]: https://github.com/Textualize/textual/compare/v0.74.0...v0.75.0
22792281
[0.74.0]: https://github.com/Textualize/textual/compare/v0.73.0...v0.74.0

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "0.75.1"
3+
version = "0.76.0"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

src/textual/app.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
from .actions import ActionParseResult, SkipAction
8181
from .await_complete import AwaitComplete
8282
from .await_remove import AwaitRemove
83-
from .binding import Binding, BindingType, _Bindings
83+
from .binding import Binding, BindingsMap, BindingType
8484
from .command import CommandPalette, Provider
8585
from .css.errors import StylesheetError
8686
from .css.query import NoMatches
@@ -3000,14 +3000,14 @@ def bell(self) -> None:
30003000
self._driver.write("\07")
30013001

30023002
@property
3003-
def _binding_chain(self) -> list[tuple[DOMNode, _Bindings]]:
3003+
def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
30043004
"""Get a chain of nodes and bindings to consider.
30053005
30063006
If no widget is focused, returns the bindings from both the screen and the app level bindings.
30073007
Otherwise, combines all the bindings from the currently focused node up the DOM to the root App.
30083008
"""
30093009
focused = self.focused
3010-
namespace_bindings: list[tuple[DOMNode, _Bindings]]
3010+
namespace_bindings: list[tuple[DOMNode, BindingsMap]]
30113011

30123012
if focused is None:
30133013
namespace_bindings = [
@@ -3048,10 +3048,11 @@ async def _check_bindings(self, key: str, priority: bool = False) -> bool:
30483048
if priority
30493049
else self.screen._modal_binding_chain
30503050
):
3051-
binding = bindings.keys.get(key)
3052-
if binding is not None and binding.priority == priority:
3053-
if await self.run_action(binding.action, namespace):
3054-
return True
3051+
key_bindings = bindings.key_to_bindings.get(key, ())
3052+
for binding in key_bindings:
3053+
if binding.priority == priority:
3054+
if await self.run_action(binding.action, namespace):
3055+
return True
30553056
return False
30563057

30573058
async def on_event(self, event: events.Event) -> None:

src/textual/binding.py

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from __future__ import annotations
99

1010
from dataclasses import dataclass
11-
from typing import TYPE_CHECKING, Iterable, NamedTuple
11+
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
1212

1313
import rich.repr
1414

@@ -64,7 +64,7 @@ class ActiveBinding(NamedTuple):
6464

6565

6666
@rich.repr.auto
67-
class _Bindings:
67+
class BindingsMap:
6868
"""Manage a set of bindings."""
6969

7070
def __init__(
@@ -83,14 +83,16 @@ def __init__(
8383
"""
8484

8585
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
86+
bindings = list(bindings)
8687
for binding in bindings:
8788
# If it's a tuple of length 3, convert into a Binding first
8889
if isinstance(binding, tuple):
8990
if len(binding) not in (2, 3):
9091
raise BindingError(
9192
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
9293
)
93-
binding = Binding(*binding)
94+
# `binding` is a tuple of 2 or 3 values at this point
95+
binding = Binding(*binding) # type: ignore[reportArgumentType]
9496

9597
# At this point we have a Binding instance, but the key may
9698
# be a list of keys, so now we unroll that single Binding
@@ -112,44 +114,72 @@ def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
112114
priority=binding.priority,
113115
)
114116

115-
self.keys: dict[str, Binding] = (
116-
{binding.key: binding for binding in make_bindings(bindings)}
117-
if bindings
118-
else {}
117+
self.key_to_bindings: dict[str, list[Binding]] = {}
118+
for binding in make_bindings(bindings or {}):
119+
self.key_to_bindings.setdefault(binding.key, []).append(binding)
120+
121+
def __iter__(self) -> Iterator[tuple[str, Binding]]:
122+
"""Iterating produces a sequence of (KEY, BINDING) tuples."""
123+
return iter(
124+
[
125+
(key, binding)
126+
for key, bindings in self.key_to_bindings.items()
127+
for binding in bindings
128+
]
119129
)
120130

121-
def copy(self) -> _Bindings:
131+
@classmethod
132+
def from_keys(cls, keys: dict[str, list[Binding]]) -> BindingsMap:
133+
"""Construct a BindingsMap from a dict of keys and bindings.
134+
135+
Args:
136+
keys: A dict that maps a key on to a list of `Binding` objects.
137+
138+
Returns:
139+
New `BindingsMap`
140+
"""
141+
bindings = cls()
142+
bindings.key_to_bindings = keys
143+
return bindings
144+
145+
def copy(self) -> BindingsMap:
122146
"""Return a copy of this instance.
123147
124148
Return:
125149
New bindings object.
126150
"""
127-
copy = _Bindings()
128-
copy.keys = self.keys.copy()
151+
copy = BindingsMap()
152+
copy.key_to_bindings = self.key_to_bindings.copy()
129153
return copy
130154

131155
def __rich_repr__(self) -> rich.repr.Result:
132-
yield self.keys
156+
yield self.key_to_bindings
133157

134158
@classmethod
135-
def merge(cls, bindings: Iterable[_Bindings]) -> _Bindings:
136-
"""Merge a bindings. Subsequent bound keys override initial keys.
159+
def merge(cls, bindings: Iterable[BindingsMap]) -> BindingsMap:
160+
"""Merge a bindings.
137161
138162
Args:
139163
bindings: A number of bindings.
140164
141165
Returns:
142-
New bindings.
166+
New `BindingsMap`.
143167
"""
144-
keys: dict[str, Binding] = {}
168+
keys: dict[str, list[Binding]] = {}
145169
for _bindings in bindings:
146-
keys.update(_bindings.keys)
147-
return _Bindings(keys.values())
170+
for key, key_bindings in _bindings.key_to_bindings.items():
171+
keys.setdefault(key, []).extend(key_bindings)
172+
return BindingsMap.from_keys(keys)
148173

149174
@property
150175
def shown_keys(self) -> list[Binding]:
151176
"""A list of bindings for shown keys."""
152-
keys = [binding for binding in self.keys.values() if binding.show]
177+
keys = [
178+
binding
179+
for bindings in self.key_to_bindings.values()
180+
for binding in bindings
181+
if binding.show
182+
]
153183
return keys
154184

155185
def bind(
@@ -173,17 +203,19 @@ def bind(
173203
"""
174204
all_keys = [key.strip() for key in keys.split(",")]
175205
for key in all_keys:
176-
self.keys[key] = Binding(
177-
key,
178-
action,
179-
description,
180-
show=bool(description and show),
181-
key_display=key_display,
182-
priority=priority,
206+
self.key_to_bindings.setdefault(key, []).append(
207+
Binding(
208+
key,
209+
action,
210+
description,
211+
show=bool(description and show),
212+
key_display=key_display,
213+
priority=priority,
214+
)
183215
)
184216

185-
def get_key(self, key: str) -> Binding:
186-
"""Get a binding if it exists.
217+
def get_bindings_for_key(self, key: str) -> list[Binding]:
218+
"""Get a list of bindings for a given key.
187219
188220
Args:
189221
key: Key to look up.
@@ -192,9 +224,9 @@ def get_key(self, key: str) -> Binding:
192224
NoBinding: If the binding does not exist.
193225
194226
Returns:
195-
A binding object for the key,
227+
A list of bindings associated with the key.
196228
"""
197229
try:
198-
return self.keys[key]
230+
return self.key_to_bindings[key]
199231
except KeyError:
200232
raise NoBinding(f"No binding for {key}") from None

src/textual/dom.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from ._context import NoActiveAppError, active_message_pump
3333
from ._node_list import NodeList
3434
from ._types import WatchCallbackType
35-
from .binding import Binding, BindingType, _Bindings
35+
from .binding import Binding, BindingsMap, BindingType
3636
from .color import BLACK, WHITE, Color
3737
from .css._error_tools import friendly_list
3838
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
@@ -158,7 +158,7 @@ class DOMNode(MessagePump):
158158
_css_type_name: str = ""
159159

160160
# Generated list of bindings
161-
_merged_bindings: ClassVar[_Bindings | None] = None
161+
_merged_bindings: ClassVar[BindingsMap | None] = None
162162

163163
_reactives: ClassVar[dict[str, Reactive]]
164164

@@ -197,7 +197,7 @@ def __init__(
197197
self._auto_refresh_timer: Timer | None = None
198198
self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)}
199199
self._bindings = (
200-
_Bindings()
200+
BindingsMap()
201201
if self._merged_bindings is None
202202
else self._merged_bindings.copy()
203203
)
@@ -590,27 +590,30 @@ def _css_bases(cls, base: Type[DOMNode]) -> Sequence[Type[DOMNode]]:
590590
return classes
591591

592592
@classmethod
593-
def _merge_bindings(cls) -> _Bindings:
593+
def _merge_bindings(cls) -> BindingsMap:
594594
"""Merge bindings from base classes.
595595
596596
Returns:
597597
Merged bindings.
598598
"""
599-
bindings: list[_Bindings] = []
599+
bindings: list[BindingsMap] = []
600600

601601
for base in reversed(cls.__mro__):
602602
if issubclass(base, DOMNode):
603603
if not base._inherit_bindings:
604604
bindings.clear()
605605
bindings.append(
606-
_Bindings(
606+
BindingsMap(
607607
base.__dict__.get("BINDINGS", []),
608608
)
609609
)
610-
keys: dict[str, Binding] = {}
610+
keys: dict[str, list[Binding]] = {}
611611
for bindings_ in bindings:
612-
keys.update(bindings_.keys)
613-
return _Bindings(keys.values())
612+
for key, key_bindings in bindings_.key_to_bindings.items():
613+
keys[key] = key_bindings
614+
615+
new_bindings = BindingsMap().from_keys(keys)
616+
return new_bindings
614617

615618
def _post_register(self, app: App) -> None:
616619
"""Called when the widget is registered

src/textual/screen.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
3737
from ._types import CallbackType
3838
from .await_complete import AwaitComplete
39-
from .binding import ActiveBinding, Binding, _Bindings
39+
from .binding import ActiveBinding, Binding, BindingsMap
4040
from .css.match import match
4141
from .css.parse import parse_selectors
4242
from .css.query import NoMatches, QueryType
@@ -289,12 +289,12 @@ def refresh_bindings(self) -> None:
289289
self.check_idle()
290290

291291
@property
292-
def _binding_chain(self) -> list[tuple[DOMNode, _Bindings]]:
292+
def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
293293
"""Binding chain from this screen."""
294294
focused = self.focused
295295
if focused is not None and focused.loading:
296296
focused = None
297-
namespace_bindings: list[tuple[DOMNode, _Bindings]]
297+
namespace_bindings: list[tuple[DOMNode, BindingsMap]]
298298

299299
if focused is None:
300300
namespace_bindings = [
@@ -309,7 +309,7 @@ def _binding_chain(self) -> list[tuple[DOMNode, _Bindings]]:
309309
return namespace_bindings
310310

311311
@property
312-
def _modal_binding_chain(self) -> list[tuple[DOMNode, _Bindings]]:
312+
def _modal_binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
313313
"""The binding chain, ignoring everything before the last modal."""
314314
binding_chain = self._binding_chain
315315
for index, (node, _bindings) in enumerate(binding_chain, 1):
@@ -327,25 +327,31 @@ def active_bindings(self) -> dict[str, ActiveBinding]:
327327
This property may be used to inspect current bindings.
328328
329329
Returns:
330-
A map of keys to a tuple containing (namespace, binding, enabled boolean).
330+
A map of keys to a tuple containing (NAMESPACE, BINDING, ENABLED).
331331
"""
332332

333333
bindings_map: dict[str, ActiveBinding] = {}
334334
for namespace, bindings in self._modal_binding_chain:
335-
for key, binding in bindings.keys.items():
335+
for key, binding in bindings:
336+
# This will call the nodes `check_action` method.
336337
action_state = self.app._check_action_state(binding.action, namespace)
337338
if action_state is False:
339+
# An action_state of False indicates the action is disabled and not shown
340+
# Note that None has a different meaning, which is why there is an `is False`
341+
# rather than a truthy check.
338342
continue
343+
enabled = bool(action_state)
339344
if existing_key_and_binding := bindings_map.get(key):
340-
_, existing_binding, _ = existing_key_and_binding
341-
if binding.priority and not existing_binding.priority:
342-
bindings_map[key] = ActiveBinding(
343-
namespace, binding, bool(action_state)
344-
)
345+
# This key has already been bound
346+
# Replace priority bindings
347+
if (
348+
binding.priority
349+
and not existing_key_and_binding.binding.priority
350+
):
351+
bindings_map[key] = ActiveBinding(namespace, binding, enabled)
345352
else:
346-
bindings_map[key] = ActiveBinding(
347-
namespace, binding, bool(action_state)
348-
)
353+
# New binding
354+
bindings_map[key] = ActiveBinding(namespace, binding, enabled)
349355

350356
return bindings_map
351357

src/textual/widgets/_footer.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,8 @@ def compose(self) -> ComposeResult:
164164
for (_, binding, enabled) in self.screen.active_bindings.values()
165165
if binding.show
166166
]
167-
action_to_bindings: defaultdict[str, list[tuple[Binding, bool]]] = defaultdict(
168-
list
169-
)
167+
action_to_bindings: defaultdict[str, list[tuple[Binding, bool]]]
168+
action_to_bindings = defaultdict(list)
170169
for binding, enabled in bindings:
171170
action_to_bindings[binding.action].append((binding, enabled))
172171

0 commit comments

Comments
 (0)