Merge pull request #941 from Textualize/bindings-order

key bindings refactor
This commit is contained in:
Will McGugan
2022-10-18 15:16:54 +01:00
committed by GitHub
6 changed files with 79 additions and 49 deletions

View File

@@ -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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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())

View File

@@ -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