mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #67 from willmcgugan/key-events
Send keys to widgets first
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user