Skip to content

Commit 552b0cc

Browse files
authored
Merge pull request #1395 from Textualize/binding-actions
optional actions
2 parents c20bf66 + 16994c5 commit 552b0cc

File tree

11 files changed

+249
-151
lines changed

11 files changed

+249
-151
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [0.8.0] - Unreleased
9+
10+
### Added
11+
12+
- Added `textual.actions.SkipAction` exception which can be raised from an action to allow parents to process bindings.
13+
814
## [0.7.0] - 2022-12-17
915

1016
### Added

docs/guide/actions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ When you click any of the links, Textual runs the `"set_background"` action to c
7575

7676
## Bindings
7777

78-
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.
78+
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.
7979

8080
=== "actions04.py"
8181

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

9393
## Namespaces
9494

95-
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.
95+
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.
9696

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

src/textual/actions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
import re
55

66

7+
class SkipAction(Exception):
8+
"""Raise in an action to skip the action (and allow any parent bindings to run)."""
9+
10+
711
class ActionError(Exception):
812
pass
913

src/textual/app.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from rich.segment import Segment, Segments
3939
from rich.traceback import Traceback
4040

41-
from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages
41+
from . import actions, Logger, LogGroup, LogVerbosity, events, log, messages
4242
from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
4343
from ._ansi_sequences import SYNC_END, SYNC_START
4444
from ._callback import invoke
@@ -47,6 +47,7 @@
4747
from ._filter import LineFilter, Monochrome
4848
from ._path import _make_path_object_relative
4949
from ._typing import Final, TypeAlias
50+
from .actions import SkipAction
5051
from .await_remove import AwaitRemove
5152
from .binding import Binding, Bindings
5253
from .css.query import NoMatches
@@ -1386,11 +1387,10 @@ async def invoke_ready_callback() -> None:
13861387
raise
13871388

13881389
finally:
1390+
self._running = True
13891391
await self._ready()
13901392
await invoke_ready_callback()
13911393

1392-
self._running = True
1393-
13941394
try:
13951395
await self._process_messages_loop()
13961396
except asyncio.CancelledError:
@@ -1753,8 +1753,8 @@ async def check_bindings(self, key: str, priority: bool = False) -> bool:
17531753
):
17541754
binding = bindings.keys.get(key)
17551755
if binding is not None and binding.priority == priority:
1756-
await self.action(binding.action, default_namespace=namespace)
1757-
return True
1756+
if await self.action(binding.action, namespace):
1757+
return True
17581758
return False
17591759

17601760
async def on_event(self, event: events.Event) -> None:
@@ -1823,32 +1823,41 @@ async def action(
18231823
async def _dispatch_action(
18241824
self, namespace: object, action_name: str, params: Any
18251825
) -> bool:
1826+
"""Dispatch an action to an action method.
1827+
1828+
Args:
1829+
namespace (object): Namespace (object) of action.
1830+
action_name (str): Name of the action.
1831+
params (Any): Action parameters.
1832+
1833+
Returns:
1834+
bool: True if handled, otherwise False.
1835+
"""
1836+
_rich_traceback_guard = True
1837+
18261838
log(
18271839
"<action>",
18281840
namespace=namespace,
18291841
action_name=action_name,
18301842
params=params,
18311843
)
1832-
_rich_traceback_guard = True
1833-
1834-
public_method_name = f"action_{action_name}"
1835-
private_method_name = f"_{public_method_name}"
18361844

1837-
private_method = getattr(namespace, private_method_name, None)
1838-
public_method = getattr(namespace, public_method_name, None)
1839-
1840-
if private_method is None and public_method is None:
1845+
try:
1846+
private_method = getattr(namespace, f"_action_{action_name}", None)
1847+
if callable(private_method):
1848+
await invoke(private_method, *params)
1849+
return True
1850+
public_method = getattr(namespace, f"action_{action_name}", None)
1851+
if callable(public_method):
1852+
await invoke(public_method, *params)
1853+
return True
18411854
log(
1842-
f"<action> {action_name!r} has no target. Couldn't find methods {public_method_name!r} or {private_method_name!r}"
1855+
f"<action> {action_name!r} has no target."
1856+
f" Could not find methods '_action_{action_name}' or 'action_{action_name}'"
18431857
)
1844-
1845-
if callable(private_method):
1846-
await invoke(private_method, *params)
1847-
return True
1848-
elif callable(public_method):
1849-
await invoke(public_method, *params)
1850-
return True
1851-
1858+
except SkipAction:
1859+
# The action method raised this to explicitly not handle the action
1860+
log("<action> {action_name!r} skipped.")
18521861
return False
18531862

18541863
async def _broker_event(
@@ -1859,7 +1868,7 @@ async def _broker_event(
18591868
Args:
18601869
event_name (str): _description_
18611870
event (events.Event): An event object.
1862-
default_namespace (object | None): TODO: _description_
1871+
default_namespace (object | None): The default namespace, where one isn't supplied.
18631872
18641873
Returns:
18651874
bool: True if an action was processed.

src/textual/demo.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Sidebar {
2929
}
3030

3131
Sidebar:focus-within {
32-
offset: 0 0 !important;
32+
offset: 0 0 !important;
3333
}
3434

3535
Sidebar.-hidden {

src/textual/screen.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,22 +295,19 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
295295
# No focus, so blur currently focused widget if it exists
296296
if self.focused is not None:
297297
self.focused.post_message_no_wait(events.Blur(self))
298-
self.focused.emit_no_wait(events.DescendantBlur(self))
299298
self.focused = None
300299
self.log.debug("focus was removed")
301300
elif widget.can_focus:
302301
if self.focused != widget:
303302
if self.focused is not None:
304303
# Blur currently focused widget
305304
self.focused.post_message_no_wait(events.Blur(self))
306-
self.focused.emit_no_wait(events.DescendantBlur(self))
307305
# Change focus
308306
self.focused = widget
309307
# Send focus event
310308
if scroll_visible:
311309
self.screen.scroll_to_widget(widget)
312310
widget.post_message_no_wait(events.Focus(self))
313-
widget.emit_no_wait(events.DescendantFocus(self))
314311
self.log.debug(widget, "was focused")
315312

316313
async def _on_idle(self, event: events.Idle) -> None:

src/textual/widget.py

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from ._segment_tools import align_lines
4545
from ._styles_cache import StylesCache
4646
from ._types import Lines
47+
from .actions import SkipAction
4748
from .await_remove import AwaitRemove
4849
from .binding import Binding
4950
from .box_model import BoxModel, get_box_model
@@ -2344,17 +2345,22 @@ def _on_enter(self, event: events.Enter) -> None:
23442345
self.mouse_over = True
23452346

23462347
def _on_focus(self, event: events.Focus) -> None:
2347-
for node in self.ancestors_with_self:
2348-
if node._has_focus_within:
2349-
self.app.update_styles(node)
23502348
self.has_focus = True
23512349
self.refresh()
2350+
self.emit_no_wait(events.DescendantFocus(self))
23522351

23532352
def _on_blur(self, event: events.Blur) -> None:
2354-
if any(node._has_focus_within for node in self.ancestors_with_self):
2355-
self.app.update_styles(self)
23562353
self.has_focus = False
23572354
self.refresh()
2355+
self.emit_no_wait(events.DescendantBlur(self))
2356+
2357+
def _on_descendant_blur(self, event: events.DescendantBlur) -> None:
2358+
if self._has_focus_within:
2359+
self.app.update_styles(self)
2360+
2361+
def _on_descendant_focus(self, event: events.DescendantBlur) -> None:
2362+
if self._has_focus_within:
2363+
self.app.update_styles(self)
23582364

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

24012407
def action_scroll_home(self) -> None:
2402-
if self._allow_scroll:
2403-
self.scroll_home()
2408+
if not self._allow_scroll:
2409+
raise SkipAction()
2410+
self.scroll_home()
24042411

24052412
def action_scroll_end(self) -> None:
2406-
if self._allow_scroll:
2407-
self.scroll_end()
2413+
if not self._allow_scroll:
2414+
raise SkipAction()
2415+
self.scroll_end()
24082416

24092417
def action_scroll_left(self) -> None:
2410-
if self.allow_horizontal_scroll:
2411-
self.scroll_left()
2418+
if not self.allow_horizontal_scroll:
2419+
raise SkipAction()
2420+
self.scroll_left()
24122421

24132422
def action_scroll_right(self) -> None:
2414-
if self.allow_horizontal_scroll:
2415-
self.scroll_right()
2423+
if not self.allow_horizontal_scroll:
2424+
raise SkipAction()
2425+
self.scroll_right()
24162426

24172427
def action_scroll_up(self) -> None:
2418-
if self.allow_vertical_scroll:
2419-
self.scroll_up()
2428+
if not self.allow_vertical_scroll:
2429+
raise SkipAction()
2430+
self.scroll_up()
24202431

24212432
def action_scroll_down(self) -> None:
2422-
if self.allow_vertical_scroll:
2423-
self.scroll_down()
2433+
if not self.allow_vertical_scroll:
2434+
raise SkipAction()
2435+
self.scroll_down()
24242436

24252437
def action_page_down(self) -> None:
2426-
if self.allow_vertical_scroll:
2427-
self.scroll_page_down()
2438+
if not self.allow_vertical_scroll:
2439+
raise SkipAction()
2440+
self.scroll_page_down()
24282441

24292442
def action_page_up(self) -> None:
2430-
if self.allow_vertical_scroll:
2431-
self.scroll_page_up()
2443+
if not self.allow_vertical_scroll:
2444+
raise SkipAction()
2445+
self.scroll_page_up()

src/textual/widgets/_static.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def _check_renderable(renderable: object):
2424
)
2525

2626

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

0 commit comments

Comments
 (0)