Skip to content

Commit d11e3f2

Browse files
authored
Merge pull request #1346 from davep/bug/1342/inherited-movement-keys
Add support for a `PRIORITY_BINDINGS` classvar to work alongside `BINDINGS`
2 parents 62d5810 + 4ee1aba commit d11e3f2

File tree

9 files changed

+704
-31
lines changed

9 files changed

+704
-31
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10+
### Added
11+
12+
- Added `PRIORITY_BINDINGS` class variable, which can be used to control if a widget's bindings have priority by default. https://github.com/Textualize/textual/issues/1343
13+
14+
### Changed
15+
16+
- Renamed the `Binding` argument `universal` to `priority`. https://github.com/Textualize/textual/issues/1343
17+
- When looking for bindings that have priority, they are now looked from `App` downwards. https://github.com/Textualize/textual/issues/1343
18+
- `BINDINGS` on an `App`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343
19+
- `BINDINGS` on a `Screen`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343
20+
1021
### Fixed
1122

1223
- Fixed validator not running on first reactive set https://github.com/Textualize/textual/pull/1359

docs/guide/input.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ Note how the footer displays bindings and makes them clickable.
127127
Multiple keys can be bound to a single action by comma-separating them.
128128
For example, `("r,t", "add_bar('red')", "Add Red")` means both ++r++ and ++t++ are bound to `add_bar('red')`.
129129

130+
131+
!!! note
132+
133+
Ordinarily a binding on a focused widget has precedence over the same key binding at a higher level. However, bindings at the `App` or `Screen` level always have priority.
134+
135+
The priority of a single binding can be controlled with the `priority` parameter of a `Binding` instance. Set it to `True` to give it priority, or `False` to not.
136+
137+
The default priority of all bindings on a class can be controlled with the `PRIORITY_BINDINGS` class variable. Set it to `True` or `False` to set the default priroty for all `BINDINGS`.
138+
130139
### Binding class
131140

132141
The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a [Binding][textual.binding.Binding] instance which exposes a few more options.

examples/five_by_five.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,10 @@ class Game(Screen):
166166
Binding("n", "new_game", "New Game"),
167167
Binding("question_mark", "push_screen('help')", "Help", key_display="?"),
168168
Binding("q", "quit", "Quit"),
169-
Binding("up,w,k", "navigate(-1,0)", "Move Up", False, universal=True),
170-
Binding("down,s,j", "navigate(1,0)", "Move Down", False, universal=True),
171-
Binding("left,a,h", "navigate(0,-1)", "Move Left", False, universal=True),
172-
Binding("right,d,l", "navigate(0,1)", "Move Right", False, universal=True),
169+
Binding("up,w,k", "navigate(-1,0)", "Move Up", False),
170+
Binding("down,s,j", "navigate(1,0)", "Move Down", False),
171+
Binding("left,a,h", "navigate(0,-1)", "Move Left", False),
172+
Binding("right,d,l", "navigate(0,1)", "Move Right", False),
173173
Binding("space", "move", "Toggle", False),
174174
]
175175
"""The bindings for the main game grid."""

src/textual/app.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ class App(Generic[ReturnType], DOMNode):
231231
}
232232
"""
233233

234+
PRIORITY_BINDINGS = True
235+
234236
SCREENS: dict[str, Screen | Callable[[], Screen]] = {}
235237
_BASE_PATH: str | None = None
236238
CSS_PATH: CSSPathType = None
@@ -298,7 +300,7 @@ def __init__(
298300

299301
self._logger = Logger(self._log)
300302

301-
self._bindings.bind("ctrl+c", "quit", show=False, universal=True)
303+
self._bindings.bind("ctrl+c", "quit", show=False, priority=True)
302304
self._refresh_required = False
303305

304306
self.design = DEFAULT_COLORS
@@ -1729,20 +1731,22 @@ def _binding_chain(self) -> list[tuple[DOMNode, Bindings]]:
17291731
]
17301732
return namespace_bindings
17311733

1732-
async def check_bindings(self, key: str, universal: bool = False) -> bool:
1734+
async def check_bindings(self, key: str, priority: bool = False) -> bool:
17331735
"""Handle a key press.
17341736
17351737
Args:
17361738
key (str): A key
1737-
universal (bool): Check universal keys if True, otherwise non-universal keys.
1739+
priority (bool): If `True` check from `App` down, otherwise from focused up.
17381740
17391741
Returns:
17401742
bool: True if the key was handled by a binding, otherwise False
17411743
"""
17421744

1743-
for namespace, bindings in self._binding_chain:
1745+
for namespace, bindings in (
1746+
reversed(self._binding_chain) if priority else self._binding_chain
1747+
):
17441748
binding = bindings.keys.get(key)
1745-
if binding is not None and binding.universal == universal:
1749+
if binding is not None and binding.priority == priority:
17461750
await self.action(binding.action, default_namespace=namespace)
17471751
return True
17481752
return False
@@ -1762,7 +1766,7 @@ async def on_event(self, event: events.Event) -> None:
17621766
self.mouse_position = Offset(event.x, event.y)
17631767
await self.screen._forward_event(event)
17641768
elif isinstance(event, events.Key):
1765-
if not await self.check_bindings(event.key, universal=True):
1769+
if not await self.check_bindings(event.key, priority=True):
17661770
forward_target = self.focused or self.screen
17671771
await forward_target._forward_event(event)
17681772
else:

src/textual/binding.py

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class NoBinding(Exception):
2020

2121
@dataclass(frozen=True)
2222
class Binding:
23+
"""The configuration of a key binding."""
24+
2325
key: str
2426
"""str: Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
2527
action: str
@@ -30,15 +32,31 @@ class Binding:
3032
"""bool: Show the action in Footer, or False to hide."""
3133
key_display: str | None = None
3234
"""str | None: How the key should be shown in footer."""
33-
universal: bool = False
34-
"""bool: Allow forwarding from app to focused widget."""
35+
priority: bool | None = None
36+
"""bool | None: Is this a priority binding, checked form app down to focused widget?"""
3537

3638

3739
@rich.repr.auto
3840
class Bindings:
3941
"""Manage a set of bindings."""
4042

41-
def __init__(self, bindings: Iterable[BindingType] | None = None) -> None:
43+
def __init__(
44+
self,
45+
bindings: Iterable[BindingType] | None = None,
46+
default_priority: bool | None = None,
47+
) -> None:
48+
"""Initialise a collection of bindings.
49+
50+
Args:
51+
bindings (Iterable[BindingType] | None, optional): An optional set of initial bindings.
52+
default_priority (bool | None, optional): The default priority of the bindings.
53+
54+
Note:
55+
The iterable of bindings can contain either a `Binding`
56+
instance, or a tuple of 3 values mapping to the first three
57+
properties of a `Binding`.
58+
"""
59+
4260
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
4361
for binding in bindings:
4462
# If it's a tuple of length 3, convert into a Binding first
@@ -49,20 +67,20 @@ def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
4967
)
5068
binding = Binding(*binding)
5169

52-
binding_keys = binding.key.split(",")
53-
if len(binding_keys) > 1:
54-
for key in binding_keys:
55-
new_binding = Binding(
56-
key=key,
57-
action=binding.action,
58-
description=binding.description,
59-
show=binding.show,
60-
key_display=binding.key_display,
61-
universal=binding.universal,
62-
)
63-
yield new_binding
64-
else:
65-
yield binding
70+
# At this point we have a Binding instance, but the key may
71+
# be a list of keys, so now we unroll that single Binding
72+
# into a (potential) collection of Binding instances.
73+
for key in binding.key.split(","):
74+
yield Binding(
75+
key=key.strip(),
76+
action=binding.action,
77+
description=binding.description,
78+
show=binding.show,
79+
key_display=binding.key_display,
80+
priority=default_priority
81+
if binding.priority is None
82+
else binding.priority,
83+
)
6684

6785
self.keys: MutableMapping[str, Binding] = (
6886
{binding.key: binding for binding in make_bindings(bindings)}
@@ -105,7 +123,7 @@ def bind(
105123
description: str = "",
106124
show: bool = True,
107125
key_display: str | None = None,
108-
universal: bool = False,
126+
priority: bool = False,
109127
) -> None:
110128
"""Bind keys to an action.
111129
@@ -115,7 +133,7 @@ def bind(
115133
description (str, optional): An optional description for the binding.
116134
show (bool, optional): A flag to say if the binding should appear in the footer.
117135
key_display (str | None, optional): Optional string to display in the footer for the key.
118-
universal (bool, optional): Allow forwarding from the app to the focused widget.
136+
priority (bool, optional): Is this a priority binding, checked form app down to focused widget?
119137
"""
120138
all_keys = [key.strip() for key in keys.split(",")]
121139
for key in all_keys:
@@ -125,7 +143,7 @@ def bind(
125143
description,
126144
show=show,
127145
key_display=key_display,
128-
universal=universal,
146+
priority=priority,
129147
)
130148

131149
def get_key(self, key: str) -> Binding:

src/textual/dom.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ class DOMNode(MessagePump):
9292
# Virtual DOM nodes
9393
COMPONENT_CLASSES: ClassVar[set[str]] = set()
9494

95+
# Should the content of BINDINGS be treated as priority bindings?
96+
PRIORITY_BINDINGS: ClassVar[bool] = False
97+
9598
# Mapping of key bindings
9699
BINDINGS: ClassVar[list[BindingType]] = []
97100

@@ -225,11 +228,18 @@ def _merge_bindings(cls) -> Bindings:
225228
"""
226229
bindings: list[Bindings] = []
227230

231+
# To start with, assume that bindings won't be priority bindings.
232+
priority = False
233+
228234
for base in reversed(cls.__mro__):
229235
if issubclass(base, DOMNode):
236+
# See if the current class wants to set the bindings as
237+
# priority bindings. If it doesn't have that property on the
238+
# class, go with what we saw last.
239+
priority = base.__dict__.get("PRIORITY_BINDINGS", priority)
230240
if not base._inherit_bindings:
231241
bindings.clear()
232-
bindings.append(Bindings(base.__dict__.get("BINDINGS", [])))
242+
bindings.append(Bindings(base.__dict__.get("BINDINGS", []), priority))
233243
keys = {}
234244
for bindings_ in bindings:
235245
keys.update(bindings_.keys)

src/textual/screen.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
class Screen(Widget):
2727
"""A widget for the root of the app."""
2828

29+
# The screen is a special case and unless a class that inherits from us
30+
# says otherwise, all screen-level bindings should be treated as having
31+
# priority.
32+
PRIORITY_BINDINGS = True
33+
2934
DEFAULT_CSS = """
3035
Screen {
3136
layout: vertical;

tests/test_binding.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,26 @@
66

77
BINDING1 = Binding("a,b", action="action1", description="description1")
88
BINDING2 = Binding("c", action="action2", description="description2")
9+
BINDING3 = Binding(" d , e ", action="action3", description="description3")
910

1011

1112
@pytest.fixture
1213
def bindings():
1314
yield Bindings([BINDING1, BINDING2])
1415

16+
@pytest.fixture
17+
def more_bindings():
18+
yield Bindings([BINDING1, BINDING2, BINDING3])
1519

1620
def test_bindings_get_key(bindings):
1721
assert bindings.get_key("b") == Binding("b", action="action1", description="description1")
1822
assert bindings.get_key("c") == BINDING2
1923
with pytest.raises(NoBinding):
2024
bindings.get_key("control+meta+alt+shift+super+hyper+t")
2125

26+
def test_bindings_get_key_spaced_list(more_bindings):
27+
assert more_bindings.get_key("d").action == more_bindings.get_key("e").action
28+
2229
def test_bindings_merge_simple(bindings):
2330
left = Bindings([BINDING1])
2431
right = Bindings([BINDING2])

0 commit comments

Comments
 (0)