Skip to content

Commit cf82a12

Browse files
committed
Merge branch 'main' into scroll-to-top
2 parents 70d4ebc + d11e3f2 commit cf82a12

File tree

13 files changed

+740
-40
lines changed

13 files changed

+740
-40
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,20 @@ 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

23+
- Fixed validator not running on first reactive set https://github.com/Textualize/textual/pull/1359
1224
- Ensure only printable characters are used as key_display https://github.com/Textualize/textual/pull/1361
1325

1426
## [0.6.0] - 2022-12-11

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.

docs/widgets/text_log.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,4 @@ This widget sends no messages.
4141

4242
## See Also
4343

44-
* [TextLog](../api/textlog.md) code reference
44+
* [TextLog](../api/text_log.md) code reference

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."""

mkdocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ nav:
112112
- "api/color.md"
113113
- "api/containers.md"
114114
- "api/data_table.md"
115-
- "api/text_log.md"
116115
- "api/directory_tree.md"
117116
- "api/dom_node.md"
118117
- "api/events.md"
@@ -130,6 +129,7 @@ nav:
130129
- "api/reactive.md"
131130
- "api/screen.md"
132131
- "api/static.md"
132+
- "api/text_log.md"
133133
- "api/timer.md"
134134
- "api/walk.md"
135135
- "api/widget.md"

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
@@ -1733,20 +1735,22 @@ def _binding_chain(self) -> list[tuple[DOMNode, Bindings]]:
17331735
]
17341736
return namespace_bindings
17351737

1736-
async def check_bindings(self, key: str, universal: bool = False) -> bool:
1738+
async def check_bindings(self, key: str, priority: bool = False) -> bool:
17371739
"""Handle a key press.
17381740
17391741
Args:
17401742
key (str): A key
1741-
universal (bool): Check universal keys if True, otherwise non-universal keys.
1743+
priority (bool): If `True` check from `App` down, otherwise from focused up.
17421744
17431745
Returns:
17441746
bool: True if the key was handled by a binding, otherwise False
17451747
"""
17461748

1747-
for namespace, bindings in self._binding_chain:
1749+
for namespace, bindings in (
1750+
reversed(self._binding_chain) if priority else self._binding_chain
1751+
):
17481752
binding = bindings.keys.get(key)
1749-
if binding is not None and binding.universal == universal:
1753+
if binding is not None and binding.priority == priority:
17501754
await self.action(binding.action, default_namespace=namespace)
17511755
return True
17521756
return False
@@ -1766,7 +1770,7 @@ async def on_event(self, event: events.Event) -> None:
17661770
self.mouse_position = Offset(event.x, event.y)
17671771
await self.screen._forward_event(event)
17681772
elif isinstance(event, events.Key):
1769-
if not await self.check_bindings(event.key, universal=True):
1773+
if not await self.check_bindings(event.key, priority=True):
17701774
forward_target = self.focused or self.screen
17711775
await forward_target._forward_event(event)
17721776
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/reactive.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,8 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None:
177177
validate_function = getattr(obj, f"validate_{name}", None)
178178
# Check if this is the first time setting the value
179179
first_set = getattr(obj, f"__first_set_{self.internal_name}", True)
180-
# Call validate, but not on first set.
181-
if callable(validate_function) and not first_set:
180+
# Call validate
181+
if callable(validate_function):
182182
value = validate_function(value)
183183
# If the value has changed, or this is the first time setting the value
184184
if current_value != value or first_set or self._always_update:

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;

0 commit comments

Comments
 (0)