Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 0 additions & 5 deletions src/textual/_linux_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
89 changes: 61 additions & 28 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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"""
Expand Down Expand Up @@ -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__()
Expand All @@ -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)
Expand All @@ -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
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
15 changes: 14 additions & 1 deletion src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Binding:
description: str
show: bool = False
key_display: str | None = None
allow_forward: bool = True


class Bindings:
Expand All @@ -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:
Expand All @@ -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."""
Expand Down
10 changes: 10 additions & 0 deletions src/textual/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Message:
"sender",
"name",
"time",
"_forwarded",
"_no_default_action",
"_stop_propagation",
"__done_event",
Expand All @@ -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
Expand All @@ -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.

Expand Down
10 changes: 7 additions & 3 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
13 changes: 7 additions & 6 deletions src/textual/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/textual/widgets/_tree_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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}
Expand Down