diff --git a/sandbox/will/screen_actions.py b/sandbox/will/screen_actions.py new file mode 100644 index 000000000..b6957349d --- /dev/null +++ b/sandbox/will/screen_actions.py @@ -0,0 +1,24 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Footer + + +class DefaultScreen(Screen): + + BINDINGS = [("f", "foo", "FOO")] + + def compose(self) -> ComposeResult: + yield Footer() + + def action_foo(self) -> None: + self.app.bell() + + +class ScreenApp(App): + def on_mount(self) -> None: + self.push_screen(DefaultScreen()) + + +app = ScreenApp() +if __name__ == "__main__": + app.run() diff --git a/src/textual/app.py b/src/textual/app.py index 0263142dd..6543e29fe 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -196,7 +196,7 @@ class App(Generic[ReturnType], DOMNode): self._logger = Logger(self._log) - self._bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) + self._bindings.bind("ctrl+c", "quit", show=False, universal=True) self._refresh_required = False self.design = DEFAULT_COLORS @@ -321,25 +321,13 @@ class App(Generic[ReturnType], DOMNode): return self.screen.focused @property - def namespace_bindings(self) -> dict[str, tuple[object, Binding]]: + def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]: """Get current bindings. If no widget is focused, then the app-level bindings are returned. If a widget is focused, then any bindings present in the active screen and app are merged and returned.""" - focused = self.focused - namespace_bindings: list[tuple[object, Bindings]] - if focused is None: - namespace_bindings = [ - (self, self._bindings), - (self.screen, self.screen._bindings), - ] - else: - namespace_bindings = [ - (node, node._bindings) for node in reversed(focused.ancestors) - ] - - namespace_binding_map: dict[str, tuple[object, Binding]] = {} - for namespace, bindings in namespace_bindings: + namespace_binding_map: dict[str, tuple[DOMNode, Binding]] = {} + for namespace, bindings in reversed(self._binding_chain): for key, binding in bindings.keys.items(): namespace_binding_map[key] = (namespace, binding) @@ -1275,22 +1263,41 @@ class App(Generic[ReturnType], DOMNode): if not self.is_headless: self.console.bell() - async def check_bindings(self, key: str) -> bool: + @property + def _binding_chain(self) -> list[tuple[DOMNode, Bindings]]: + """Get a chain of nodes and bindings to consider. If no widget is focused, returns the bindings from both the screen and the app level bindings. Otherwise, combines all the bindings from the currently focused node up the DOM to the root App. + + Returns: + list[tuple[DOMNode, Bindings]]: List of DOM nodes and their bindings. + """ + focused = self.focused + namespace_bindings: list[tuple[DOMNode, Bindings]] + if focused is None: + namespace_bindings = [ + (self.screen, self.screen._bindings), + (self, self._bindings), + ] + else: + namespace_bindings = [(node, node._bindings) for node in focused.ancestors] + return namespace_bindings + + async def check_bindings(self, key: str, universal: bool = False) -> bool: """Handle a key press. Args: key (str): A key + universal (bool): Check universal keys if True, otherwise non-universal keys. Returns: bool: True if the key was handled by a binding, otherwise False """ - try: - namespace, binding = self.namespace_bindings[key] - except KeyError: - return False - else: - await self.action(binding.action, default_namespace=namespace) - return True + + for namespace, bindings in self._binding_chain: + binding = bindings.keys.get(key) + if binding is not None and binding.universal == universal: + await self.action(binding.action, default_namespace=namespace) + return True + return False async def on_event(self, event: events.Event) -> None: # Handle input events that haven't been forwarded @@ -1305,11 +1312,11 @@ class App(Generic[ReturnType], DOMNode): if isinstance(event, events.MouseEvent): # Record current mouse position on App self.mouse_position = Offset(event.x, event.y) - if isinstance(event, events.Key): - forward_target = self.focused or self.screen - await forward_target._forward_event(event) - else: await self.screen._forward_event(event) + elif isinstance(event, events.Key): + if not await self.check_bindings(event.key, universal=True): + forward_target = self.focused or self.screen + await forward_target._forward_event(event) elif isinstance(event, events.Paste): if self.focused is not None: @@ -1440,7 +1447,7 @@ class App(Generic[ReturnType], DOMNode): if parent is not None: parent.refresh(layout=True) - async def action_check_binding(self, key: str) -> None: + async def action_check_bindings(self, key: str) -> None: await self.check_bindings(key) async def action_quit(self) -> None: diff --git a/src/textual/binding.py b/src/textual/binding.py index 498201c76..3c54a1add 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -34,7 +34,7 @@ class Binding: """Show the action in Footer, or False to hide.""" key_display: str | None = None """How the key should be shown in footer.""" - allow_forward: bool = True + universal: bool = False """Allow forwarding from app to focused widget.""" @@ -55,7 +55,7 @@ class Bindings: description=binding.description, show=binding.show, key_display=binding.key_display, - allow_forward=binding.allow_forward, + universal=binding.universal, ) yield new_binding else: @@ -103,7 +103,7 @@ class Bindings: description: str = "", show: bool = True, key_display: str | None = None, - allow_forward: bool = True, + universal: bool = False, ) -> None: all_keys = [key.strip() for key in keys.split(",")] for key in all_keys: @@ -113,7 +113,7 @@ class Bindings: description, show=show, key_display=key_display, - allow_forward=allow_forward, + universal=universal, ) def get_key(self, key: str) -> Binding: @@ -121,9 +121,3 @@ class Bindings: return self.keys[key] except KeyError: raise NoBinding(f"No binding for {key}") from None - - def allow_forward(self, key: str) -> bool: - binding = self.keys.get(key, None) - if binding is None: - return True - return binding.allow_forward diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 6470b7e78..7d7712a81 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -571,6 +571,7 @@ class MessagePump(metaclass=MessagePumpMeta): return public_handler or private_handler + handled = False invoked_method = None key_name = event.key_name if not key_name: @@ -583,10 +584,12 @@ class MessagePump(metaclass=MessagePumpMeta): _raise_duplicate_key_handlers_error( key_name, invoked_method.__name__, key_method.__name__ ) - await invoke(key_method, event) + # If key handlers return False, then they are not considered handled + # This allows key handlers to do some conditional logic + handled = (await invoke(key_method, event)) != False invoked_method = key_method - return invoked_method is not None + return handled async def on_timer(self, event: events.Timer) -> None: event.prevent_default() diff --git a/src/textual/widget.py b/src/textual/widget.py index dab9e06d1..5b73d2063 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1900,12 +1900,7 @@ class Widget(DOMNode): await self.handle_key(event) async def handle_key(self, event: events.Key) -> bool: - try: - binding = self._bindings.get_key(event.key) - except NoBinding: - return await self.dispatch_key(event) - await self.action(binding.action) - return True + return await self.dispatch_key(event) async def _on_compose(self, event: events.Compose) -> None: widgets = list(self.compose()) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 33e12375d..63b2265aa 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -87,7 +87,11 @@ class Footer(Widget): highlight_key_style = self.get_component_rich_style("footer--highlight-key") key_style = self.get_component_rich_style("footer--key") - bindings = self.app.bindings.shown_keys + bindings = [ + binding + for (_namespace, binding) in self.app.namespace_bindings.values() + if binding.show + ] action_to_bindings = defaultdict(list) for binding in bindings: @@ -107,7 +111,10 @@ class Footer(Widget): f" {binding.description} ", highlight_style if hovered else base_style, ), - meta={"@click": f"app.press('{binding.key}')", "key": binding.key}, + meta={ + "@click": f"app.check_bindings('{binding.key}')", + "key": binding.key, + }, ) text.append_text(key_text) return text