diff --git a/CHANGELOG.md b/CHANGELOG.md index 7771b51cbc..34b1bef67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index d8f55055de..7f4df92b2f 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -172,11 +172,6 @@ def run_input_thread(self, loop) -> None: 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) diff --git a/src/textual/app.py b/src/textual/app.py index 3dd9e08a04..175836b2a4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -9,7 +9,6 @@ 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 ._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 @@ 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 @@ def __init__( 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 @@ def view(self) -> DockView: 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 @@ async def bind( 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 @@ async def push_view(self, view: ViewType) -> ViewType: 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 @@ async def set_mouse_over(self, widget: Widget | None) -> None: 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 @@ def display(self, renderable: RenderableType) -> None: 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 @@ async def press(self, key: str) -> bool: 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 @@ async def broker_event( 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() diff --git a/src/textual/binding.py b/src/textual/binding.py index 169e3669b0..f5ec828a2e 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -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 @@ def bind( 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 @@ def get_key(self, key: str) -> Binding: 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.""" diff --git a/src/textual/message.py b/src/textual/message.py index f37f95a537..efd45184a4 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -18,6 +18,7 @@ class Message: "sender", "name", "time", + "_forwarded", "_no_default_action", "_stop_propagation", "__done_event", @@ -37,6 +38,7 @@ def __init__(self, sender: MessageTarget) -> None: 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 @@ def _done_event(self) -> Event: 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. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 5276621671..8206963960 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -246,7 +246,10 @@ async def on_event(self, event: events.Event) -> None: 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 @@ async def on_message(self, message: Message) -> None: 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: diff --git a/src/textual/view.py b/src/textual/view.py index 6f24ed0cdc..f2e72c3ddd 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -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 @@ async def message_update(self, message: UpdateMessage) -> None: 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 @@ async def _on_mouse_move(self, event: events.MouseMove) -> None: ) 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 @@ async def forward_event(self, event: events.Event) -> None: 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] diff --git a/src/textual/widget.py b/src/textual/widget.py index 810ca84397..866b86493d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -220,6 +220,7 @@ async def call_later(self, callback: Callable, *args, **kwargs) -> None: 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: diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index ed2a794cce..5fadc033ac 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -104,8 +104,8 @@ def __init__( ) 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 @@ async def add( 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}