mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into nested-height-fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -29,7 +29,7 @@ Sidebar {
|
||||
}
|
||||
|
||||
Sidebar:focus-within {
|
||||
offset: 0 0 !important;
|
||||
offset: 0 0 !important;
|
||||
}
|
||||
|
||||
Sidebar.-hidden {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user