Merge branch 'main' into nested-height-fix

This commit is contained in:
Will McGugan
2022-12-20 11:14:45 +00:00
committed by GitHub
11 changed files with 244 additions and 150 deletions

View File

@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed issues with nested auto dimensions https://github.com/Textualize/textual/issues/1402
### 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

View File

@@ -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"
@@ -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.

View File

@@ -4,6 +4,10 @@ import ast
import re
class SkipAction(Exception):
"""Raise in an action to skip the action (and allow any parent bindings to run)."""
class ActionError(Exception):
pass

View File

@@ -38,7 +38,7 @@ from rich.protocol import is_renderable
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
@@ -47,6 +47,7 @@ from ._event_broker import NoHandler, extract_handler_actions
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
@@ -1386,11 +1387,10 @@ class App(Generic[ReturnType], DOMNode):
raise
finally:
self._running = True
await self._ready()
await invoke_ready_callback()
self._running = True
try:
await self._process_messages_loop()
except asyncio.CancelledError:
@@ -1753,8 +1753,8 @@ class App(Generic[ReturnType], DOMNode):
):
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:
@@ -1823,32 +1823,41 @@ class App(Generic[ReturnType], DOMNode):
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(
@@ -1859,7 +1868,7 @@ class App(Generic[ReturnType], DOMNode):
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.

View File

@@ -295,7 +295,6 @@ class Screen(Widget):
# 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:
@@ -303,14 +302,12 @@ class Screen(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:

View File

@@ -44,6 +44,7 @@ from ._layout import Layout
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
@@ -2350,17 +2351,22 @@ class Widget(DOMNode):
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:
@@ -2405,33 +2411,41 @@ class Widget(DOMNode):
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()

View File

@@ -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:

File diff suppressed because one or more lines are too long

View File

@@ -176,4 +176,8 @@ def test_key_display(snap_compare):
def test_demo(snap_compare):
"""Test the demo app (python -m textual)"""
assert snap_compare(Path("../../src/textual/demo.py"))
assert snap_compare(
Path("../../src/textual/demo.py"),
press=["down", "down", "down", "_"],
terminal_size=(100, 30),
)

View File

@@ -11,13 +11,13 @@ background relating to this.
from __future__ import annotations
import pytest
from textual.actions import SkipAction
from textual.app import App, ComposeResult
from textual.widgets import Static
from textual.screen import Screen
from textual.binding import Binding
from textual.containers import Container
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Static
##############################################################################
# These are the movement keys within Textual; they kind of have a special
@@ -574,6 +574,7 @@ class PriorityOverlapScreen(Screen):
def on_mount(self) -> None:
self.query_one(PriorityOverlapWidget).focus()
class PriorityOverlapApp(AppKeyRecorder):
"""An application with a priority binding."""
@@ -607,3 +608,40 @@ async def test_overlapping_priority_bindings() -> None:
"app_e",
"screen_f",
]
async def test_skip_action() -> None:
"""Test that a binding may be skipped by an action raising SkipAction"""
class Handle(Widget, can_focus=True):
BINDINGS = [("t", "test('foo')", "Test")]
def action_test(self, text: str) -> None:
self.app.exit(text)
no_handle_invoked = False
class NoHandle(Widget, can_focus=True):
BINDINGS = [("t", "test('bar')", "Test")]
def action_test(self, text: str) -> bool:
nonlocal no_handle_invoked
no_handle_invoked = True
raise SkipAction()
class SkipApp(App):
def compose(self) -> ComposeResult:
yield Handle(NoHandle())
def on_mount(self) -> None:
self.query_one(NoHandle).focus()
async with SkipApp().run_test() as pilot:
# Check the NoHandle widget has focus
assert pilot.app.query_one(NoHandle).has_focus
# Press the "t" key
await pilot.press("t")
# Check the action on the no handle widget was called
assert no_handle_invoked
# Check the return value, confirming that the action on Handle was called
assert pilot.app.return_value == "foo"