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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [0.8.0] - Unreleased

### Added

- Added `textual.actions.SkipAction` exception which can be raised from an action to allow parents to process bindings.

## [0.7.0] - 2022-12-17

### Added
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ When you click any of the links, Textual runs the `"set_background"` action to c

## Bindings

Textual will also run actions bound to keys. The following example adds key [bindings](./input.md#bindings) for the ++r++, ++g++, and ++b++ keys which call the `"set_background"` action.
Textual will run actions bound to keys. The following example adds key [bindings](./input.md#bindings) for the ++r++, ++g++, and ++b++ keys which call the `"set_background"` action.

=== "actions04.py"

Expand All @@ -92,7 +92,7 @@ If you run this example, you can change the background by pressing keys in addit

## Namespaces

Textual will look for action methods on the widget or app where they are used. If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions.
Textual will look for action methods in the class where they are defined (App, Screen, or Widget). If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions.

The following example defines a custom widget with its own `set_background` action.

Expand Down
4 changes: 4 additions & 0 deletions src/textual/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import re


class SkipAction(Exception):
"""Raise in an action to skip the action (and allow any parent bindings to run)."""


class ActionError(Exception):
pass

Expand Down
55 changes: 32 additions & 23 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from rich.segment import Segment, Segments
from rich.traceback import Traceback

from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages
from . import actions, Logger, LogGroup, LogVerbosity, events, log, messages
from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
from ._ansi_sequences import SYNC_END, SYNC_START
from ._callback import invoke
Expand All @@ -47,6 +47,7 @@
from ._filter import LineFilter, Monochrome
from ._path import _make_path_object_relative
from ._typing import Final, TypeAlias
from .actions import SkipAction
from .await_remove import AwaitRemove
from .binding import Binding, Bindings
from .css.query import NoMatches
Expand Down Expand Up @@ -1386,11 +1387,10 @@ async def invoke_ready_callback() -> None:
raise

finally:
self._running = True
await self._ready()
await invoke_ready_callback()

self._running = True

try:
await self._process_messages_loop()
except asyncio.CancelledError:
Expand Down Expand Up @@ -1753,8 +1753,8 @@ async def check_bindings(self, key: str, priority: bool = False) -> bool:
):
binding = bindings.keys.get(key)
if binding is not None and binding.priority == priority:
await self.action(binding.action, default_namespace=namespace)
return True
if await self.action(binding.action, namespace):
return True
return False

async def on_event(self, event: events.Event) -> None:
Expand Down Expand Up @@ -1823,32 +1823,41 @@ async def action(
async def _dispatch_action(
self, namespace: object, action_name: str, params: Any
) -> bool:
"""Dispatch an action to an action method.

Args:
namespace (object): Namespace (object) of action.
action_name (str): Name of the action.
params (Any): Action parameters.

Returns:
bool: True if handled, otherwise False.
"""
_rich_traceback_guard = True

log(
"<action>",
namespace=namespace,
action_name=action_name,
params=params,
)
_rich_traceback_guard = True

public_method_name = f"action_{action_name}"
private_method_name = f"_{public_method_name}"

private_method = getattr(namespace, private_method_name, None)
public_method = getattr(namespace, public_method_name, None)

if private_method is None and public_method is None:
try:
private_method = getattr(namespace, f"_action_{action_name}", None)
if callable(private_method):
await invoke(private_method, *params)
return True
public_method = getattr(namespace, f"action_{action_name}", None)
if callable(public_method):
await invoke(public_method, *params)
return True
log(
f"<action> {action_name!r} has no target. Couldn't find methods {public_method_name!r} or {private_method_name!r}"
f"<action> {action_name!r} has no target."
f" Could not find methods '_action_{action_name}' or 'action_{action_name}'"
)

if callable(private_method):
await invoke(private_method, *params)
return True
elif callable(public_method):
await invoke(public_method, *params)
return True

except SkipAction:
# The action method raised this to explicitly not handle the action
log("<action> {action_name!r} skipped.")
return False

async def _broker_event(
Expand All @@ -1859,7 +1868,7 @@ async def _broker_event(
Args:
event_name (str): _description_
event (events.Event): An event object.
default_namespace (object | None): TODO: _description_
default_namespace (object | None): The default namespace, where one isn't supplied.

Returns:
bool: True if an action was processed.
Expand Down
2 changes: 1 addition & 1 deletion src/textual/demo.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Sidebar {
}

Sidebar:focus-within {
offset: 0 0 !important;
offset: 0 0 !important;
}

Sidebar.-hidden {
Expand Down
3 changes: 0 additions & 3 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,22 +295,19 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
# No focus, so blur currently focused widget if it exists
if self.focused is not None:
self.focused.post_message_no_wait(events.Blur(self))
self.focused.emit_no_wait(events.DescendantBlur(self))
self.focused = None
self.log.debug("focus was removed")
elif widget.can_focus:
if self.focused != widget:
if self.focused is not None:
# Blur currently focused widget
self.focused.post_message_no_wait(events.Blur(self))
self.focused.emit_no_wait(events.DescendantBlur(self))
# Change focus
self.focused = widget
# Send focus event
if scroll_visible:
self.screen.scroll_to_widget(widget)
widget.post_message_no_wait(events.Focus(self))
widget.emit_no_wait(events.DescendantFocus(self))
self.log.debug(widget, "was focused")

async def _on_idle(self, event: events.Idle) -> None:
Expand Down
56 changes: 35 additions & 21 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from ._segment_tools import align_lines
from ._styles_cache import StylesCache
from ._types import Lines
from .actions import SkipAction
from .await_remove import AwaitRemove
from .binding import Binding
from .box_model import BoxModel, get_box_model
Expand Down Expand Up @@ -2344,17 +2345,22 @@ def _on_enter(self, event: events.Enter) -> None:
self.mouse_over = True

def _on_focus(self, event: events.Focus) -> None:
for node in self.ancestors_with_self:
if node._has_focus_within:
self.app.update_styles(node)
self.has_focus = True
self.refresh()
self.emit_no_wait(events.DescendantFocus(self))

def _on_blur(self, event: events.Blur) -> None:
if any(node._has_focus_within for node in self.ancestors_with_self):
self.app.update_styles(self)
self.has_focus = False
self.refresh()
self.emit_no_wait(events.DescendantBlur(self))

def _on_descendant_blur(self, event: events.DescendantBlur) -> None:
if self._has_focus_within:
self.app.update_styles(self)

def _on_descendant_focus(self, event: events.DescendantBlur) -> None:
if self._has_focus_within:
self.app.update_styles(self)

def _on_mouse_scroll_down(self, event) -> None:
if self.allow_vertical_scroll:
Expand Down Expand Up @@ -2399,33 +2405,41 @@ def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None:
self.scroll_to_region(message.region, animate=True)

def action_scroll_home(self) -> None:
if self._allow_scroll:
self.scroll_home()
if not self._allow_scroll:
raise SkipAction()
self.scroll_home()

def action_scroll_end(self) -> None:
if self._allow_scroll:
self.scroll_end()
if not self._allow_scroll:
raise SkipAction()
self.scroll_end()

def action_scroll_left(self) -> None:
if self.allow_horizontal_scroll:
self.scroll_left()
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_left()

def action_scroll_right(self) -> None:
if self.allow_horizontal_scroll:
self.scroll_right()
if not self.allow_horizontal_scroll:
raise SkipAction()
self.scroll_right()

def action_scroll_up(self) -> None:
if self.allow_vertical_scroll:
self.scroll_up()
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_up()

def action_scroll_down(self) -> None:
if self.allow_vertical_scroll:
self.scroll_down()
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_down()

def action_page_down(self) -> None:
if self.allow_vertical_scroll:
self.scroll_page_down()
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_page_down()

def action_page_up(self) -> None:
if self.allow_vertical_scroll:
self.scroll_page_up()
if not self.allow_vertical_scroll:
raise SkipAction()
self.scroll_page_up()
2 changes: 1 addition & 1 deletion src/textual/widgets/_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def _check_renderable(renderable: object):
)


class Static(Widget):
class Static(Widget, inherit_bindings=False):
"""A widget to display simple static content, or use as a base class for more complex widgets.

Args:
Expand Down
Loading