Merge pull request #67 from willmcgugan/key-events

Send keys to widgets first
This commit is contained in:
Will McGugan
2021-08-19 20:48:51 +01:00
committed by GitHub
9 changed files with 103 additions and 45 deletions

View File

@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Event handler event argument is optional.
- Fixed exception in clock example https://github.com/willmcgugan/textual/issues/52
- Added Message.wait() which waits for a message to be processed
- Key events are now sent to widgets first, before processing bindings
## [0.1.9] - 2021-08-06

View File

@@ -172,11 +172,6 @@ class LinuxDriver(Driver):
pass # TODO: log
def _run_input_thread(self, loop) -> None:
def send_event(event: events.Event) -> None:
asyncio.run_coroutine_threadsafe(
self._target.post_message(event),
loop=loop,
)
selector = selectors.DefaultSelector()
selector.register(self.fileno, selectors.EVENT_READ)

View File

@@ -9,7 +9,6 @@ import warnings
from rich.control import Control
import rich.repr
from rich.screen import Screen
from rich import get_console
from rich.console import Console, RenderableType
from rich.traceback import Traceback
@@ -22,12 +21,10 @@ from . import log
from ._callback import invoke
from ._context import active_app
from ._event_broker import extract_handler_actions, NoHandler
from ._types import MessageTarget
from .driver import Driver
from .layouts.dock import DockLayout, Dock
from ._linux_driver import LinuxDriver
from .message_pump import MessagePump
from .message import Message
from ._profile import timer
from .view import View
from .views import DockView
@@ -50,20 +47,10 @@ else:
uvloop.install()
class PanicMessage(Message):
def __init__(self, sender: MessageTarget, traceback: Traceback) -> None:
self.traceback = traceback
super().__init__(sender)
class ActionError(Exception):
pass
class ShutdownError(Exception):
pass
@rich.repr.auto
class App(MessagePump):
"""The base class for Textual Applications"""
@@ -113,7 +100,7 @@ class App(MessagePump):
self.log_file = open(log, "wt") if log else None
self.log_verbosity = log_verbosity
self.bindings.bind("ctrl+c", "quit", show=False)
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
self._refresh_required = False
super().__init__()
@@ -137,6 +124,12 @@ class App(MessagePump):
return self._view_stack[-1]
def log(self, *args: Any, verbosity: int = 1) -> None:
"""Write to logs.
Args:
*args (Any): Positional arguments are converted to string and written to logs.
verbosity (int, optional): Verbosity level 0-3. Defaults to 1.
"""
try:
if self.log_file and verbosity <= self.log_verbosity:
output = f" ".join(str(arg) for arg in args)
@@ -153,6 +146,15 @@ class App(MessagePump):
show: bool = True,
key_display: str | None = None,
) -> None:
"""Bind a key to an action.
Args:
keys (str): A comma separated list of keys, i.e.
action (str): Action to bind to.
description (str, optional): Short description of action. Defaults to "".
show (bool, optional): Show key in UI. Defaults to True.
key_display (str, optional): Replacement text for key, or None to use default. Defaults to None.
"""
self.bindings.bind(
keys, action, description, show=show, key_display=key_display
)
@@ -184,14 +186,15 @@ class App(MessagePump):
self._view_stack.append(view)
return view
def on_keyboard_interupt(self) -> None:
loop = asyncio.get_event_loop()
event = events.ShutdownRequest(sender=self)
asyncio.run_coroutine_threadsafe(self.post_message(event), loop=loop)
async def set_focus(self, widget: Widget | None) -> None:
"""Focus (or unfocus) a widget. A focused widget will receive key events first.
Args:
widget (Widget): [description]
"""
log("set_focus", widget)
if widget == self.focused:
# Widget is already focused
return
if widget is None:
@@ -224,7 +227,11 @@ class App(MessagePump):
self.mouse_over = widget
async def capture_mouse(self, widget: Widget | None) -> None:
"""Send all Mouse events to a given widget."""
"""Send all mouse events to the given widget, disable mouse capture.
Args:
widget (Widget | None): If a widget, capture mouse event, or None to end mouse capture.
"""
if widget == self.mouse_captured:
return
if self.mouse_captured is not None:
@@ -341,9 +348,26 @@ class App(MessagePump):
self.panic()
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
"""Get the widget under the given coordinates.
Args:
x (int): X Coord.
y (int): Y Coord.
Returns:
tuple[Widget, Region]: The widget and the widget's screen region.
"""
return self.view.get_widget_at(x, y)
async def press(self, key: str) -> bool:
"""Handle a key press.
Args:
key (str): A key
Returns:
bool: True if the key was handled by a binding, otherwise False
"""
try:
binding = self.bindings.get_key(key)
except NoBinding:
@@ -353,17 +377,22 @@ class App(MessagePump):
return True
async def on_event(self, event: events.Event) -> None:
if isinstance(event, events.Key):
if await self.press(event.key):
return
await super().on_event(event)
if isinstance(event, events.InputEvent):
# Handle input events that haven't been forwarded
# If the event has been forwaded it may have bubbled up back to the App
if isinstance(event, events.InputEvent) and not event.is_forwarded:
if isinstance(event, events.MouseEvent):
# Record current mouse position on App
self.mouse_position = Offset(event.x, event.y)
if isinstance(event, events.Key) and self.focused is not None:
await self.focused.forward_event(event)
await self.view.forward_event(event)
# Key events are sent direct to focused widget
if self.bindings.allow_forward(event.key):
await self.focused.forward_event(event)
else:
# Key has allow_forward=False which disallows forward to focused widget
await super().on_event(event)
else:
# Forward the event to the view
await self.view.forward_event(event)
else:
await super().on_event(event)
@@ -425,6 +454,10 @@ class App(MessagePump):
return False
return True
async def on_key(self, event: events.Key) -> None:
self.log("App.on_key")
await self.press(event.key)
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
log("shutdown request")
await self.close_messages()

View File

@@ -13,6 +13,7 @@ class Binding:
description: str
show: bool = False
key_display: str | None = None
allow_forward: bool = True
class Bindings:
@@ -33,11 +34,17 @@ class Bindings:
description: str = "",
show: bool = True,
key_display: str | None = None,
allow_forward: bool = True,
) -> None:
all_keys = [key.strip() for key in keys.split(",")]
for key in all_keys:
self.keys[key] = Binding(
key, action, description, show=show, key_display=key_display
key,
action,
description,
show=show,
key_display=key_display,
allow_forward=True,
)
def get_key(self, key: str) -> Binding:
@@ -46,6 +53,12 @@ class Bindings:
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
class BindingStack:
"""Manage a stack of bindings."""

View File

@@ -18,6 +18,7 @@ class Message:
"sender",
"name",
"time",
"_forwarded",
"_no_default_action",
"_stop_propagation",
"__done_event",
@@ -37,6 +38,7 @@ class Message:
self.sender = sender
self.name = camel_to_snake(self.__class__.__name__.replace("Message", ""))
self.time = monotonic()
self._forwarded = False
self._no_default_action = False
self._stop_propagation = False
self.__done_event: Event | None = None
@@ -56,6 +58,14 @@ class Message:
self.__done_event = Event()
return self.__done_event
@property
def is_forwarded(self) -> bool:
return self._forwarded
def set_forwarded(self) -> None:
"""Mark this event as being forwarded."""
self._forwarded = True
def can_replace(self, message: "Message") -> bool:
"""Check if another message may supersede this one.

View File

@@ -246,7 +246,10 @@ class MessagePump:
await invoke(method, event)
if event.bubble and self._parent and not event._stop_propagation:
if event.sender != self._parent and self.is_parent_active:
if event.sender == self._parent:
# parent is sender, so we stop propagation after parent
event.stop()
if self.is_parent_active:
await self._parent.post_message(event)
async def on_message(self, message: Message) -> None:
@@ -260,8 +263,9 @@ class MessagePump:
if message.bubble and self._parent and not message._stop_propagation:
if message.sender == self._parent:
pass
elif not self._parent._closed and not self._parent._closing:
# parent is sender, so we stop propagation after parent
message.stop()
if not self._parent._closed and not self._parent._closing:
await self._parent.post_message(message)
def post_message_no_wait(self, message: Message) -> bool:

View File

@@ -29,7 +29,6 @@ class View(Widget):
def __init__(self, layout: Layout = None, name: str | None = None) -> None:
self.layout: Layout = layout or self.layout_factory()
self.mouse_over: Widget | None = None
self.focused: Widget | None = None
self.widgets: set[Widget] = set()
self.named_widgets: dict[str, Widget] = {}
self._mouse_style: Style = Style()
@@ -98,7 +97,7 @@ class View(Widget):
if message.layout:
await self.root_view.refresh_layout()
self.log("LAYOUT")
# await self.app.refresh()
display_update = self.root_view.layout.update_widget(self.console, widget)
if display_update is not None:
self.app.display(display_update)
@@ -209,7 +208,7 @@ class View(Widget):
)
async def forward_event(self, event: events.Event) -> None:
event.set_forwarded()
if isinstance(event, (events.Enter, events.Leave)):
await self.post_message(event)
@@ -237,12 +236,14 @@ class View(Widget):
widget, _region = self.get_widget_at(event.x, event.y)
except NoWidget:
return
scroll_widget = widget or self.focused
scroll_widget = widget
if scroll_widget is not None:
await scroll_widget.forward_event(event)
else:
if self.focused is not None:
await self.focused.forward_event(event)
self.log("view.forwarded", event)
await self.post_message(event)
# if self.focused is not None:
# await self.focused.forward_event(event)
async def action_toggle(self, name: str) -> None:
widget = self.named_widgets[name]

View File

@@ -220,6 +220,7 @@ class Widget(MessagePump):
await self.app.call_later(callback, *args, **kwargs)
async def forward_event(self, event: events.Event) -> None:
event.set_forwarded()
await self.post_message(event)
def refresh(self, repaint: bool = True, layout: bool = False) -> None:

View File

@@ -104,8 +104,8 @@ class TreeControl(Generic[NodeDataType], Widget):
)
self._tree.label = self.root
self.nodes[NodeID(self._node_id)] = self.root
self.padding = padding
super().__init__(name=name)
self.padding = padding
hover_node: Reactive[NodeID | None] = Reactive(None)
@@ -127,7 +127,7 @@ class TreeControl(Generic[NodeDataType], Widget):
self.refresh()
def render(self) -> RenderableType:
return Padding(self._tree, self.padding)
return self._tree
def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType:
meta = {"@click": f"click_label({node.id})", "tree_node": node.id}