mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #1346 from davep/bug/1342/inherited-movement-keys
Add support for a `PRIORITY_BINDINGS` classvar to work alongside `BINDINGS`
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added `PRIORITY_BINDINGS` class variable, which can be used to control if a widget's bindings have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the `Binding` argument `universal` to `priority`. https://github.com/Textualize/textual/issues/1343
|
||||
- When looking for bindings that have priority, they are now looked from `App` downwards. https://github.com/Textualize/textual/issues/1343
|
||||
- `BINDINGS` on an `App`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
- `BINDINGS` on a `Screen`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed validator not running on first reactive set https://github.com/Textualize/textual/pull/1359
|
||||
|
||||
@@ -127,6 +127,15 @@ Note how the footer displays bindings and makes them clickable.
|
||||
Multiple keys can be bound to a single action by comma-separating them.
|
||||
For example, `("r,t", "add_bar('red')", "Add Red")` means both ++r++ and ++t++ are bound to `add_bar('red')`.
|
||||
|
||||
|
||||
!!! note
|
||||
|
||||
Ordinarily a binding on a focused widget has precedence over the same key binding at a higher level. However, bindings at the `App` or `Screen` level always have priority.
|
||||
|
||||
The priority of a single binding can be controlled with the `priority` parameter of a `Binding` instance. Set it to `True` to give it priority, or `False` to not.
|
||||
|
||||
The default priority of all bindings on a class can be controlled with the `PRIORITY_BINDINGS` class variable. Set it to `True` or `False` to set the default priroty for all `BINDINGS`.
|
||||
|
||||
### Binding class
|
||||
|
||||
The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a [Binding][textual.binding.Binding] instance which exposes a few more options.
|
||||
|
||||
@@ -166,10 +166,10 @@ class Game(Screen):
|
||||
Binding("n", "new_game", "New Game"),
|
||||
Binding("question_mark", "push_screen('help')", "Help", key_display="?"),
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("up,w,k", "navigate(-1,0)", "Move Up", False, universal=True),
|
||||
Binding("down,s,j", "navigate(1,0)", "Move Down", False, universal=True),
|
||||
Binding("left,a,h", "navigate(0,-1)", "Move Left", False, universal=True),
|
||||
Binding("right,d,l", "navigate(0,1)", "Move Right", False, universal=True),
|
||||
Binding("up,w,k", "navigate(-1,0)", "Move Up", False),
|
||||
Binding("down,s,j", "navigate(1,0)", "Move Down", False),
|
||||
Binding("left,a,h", "navigate(0,-1)", "Move Left", False),
|
||||
Binding("right,d,l", "navigate(0,1)", "Move Right", False),
|
||||
Binding("space", "move", "Toggle", False),
|
||||
]
|
||||
"""The bindings for the main game grid."""
|
||||
|
||||
@@ -231,6 +231,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||
}
|
||||
"""
|
||||
|
||||
PRIORITY_BINDINGS = True
|
||||
|
||||
SCREENS: dict[str, Screen | Callable[[], Screen]] = {}
|
||||
_BASE_PATH: str | None = None
|
||||
CSS_PATH: CSSPathType = None
|
||||
@@ -298,7 +300,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
self._logger = Logger(self._log)
|
||||
|
||||
self._bindings.bind("ctrl+c", "quit", show=False, universal=True)
|
||||
self._bindings.bind("ctrl+c", "quit", show=False, priority=True)
|
||||
self._refresh_required = False
|
||||
|
||||
self.design = DEFAULT_COLORS
|
||||
@@ -1729,20 +1731,22 @@ class App(Generic[ReturnType], DOMNode):
|
||||
]
|
||||
return namespace_bindings
|
||||
|
||||
async def check_bindings(self, key: str, universal: bool = False) -> bool:
|
||||
async def check_bindings(self, key: str, priority: bool = False) -> bool:
|
||||
"""Handle a key press.
|
||||
|
||||
Args:
|
||||
key (str): A key
|
||||
universal (bool): Check universal keys if True, otherwise non-universal keys.
|
||||
priority (bool): If `True` check from `App` down, otherwise from focused up.
|
||||
|
||||
Returns:
|
||||
bool: True if the key was handled by a binding, otherwise False
|
||||
"""
|
||||
|
||||
for namespace, bindings in self._binding_chain:
|
||||
for namespace, bindings in (
|
||||
reversed(self._binding_chain) if priority else self._binding_chain
|
||||
):
|
||||
binding = bindings.keys.get(key)
|
||||
if binding is not None and binding.universal == universal:
|
||||
if binding is not None and binding.priority == priority:
|
||||
await self.action(binding.action, default_namespace=namespace)
|
||||
return True
|
||||
return False
|
||||
@@ -1762,7 +1766,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.mouse_position = Offset(event.x, event.y)
|
||||
await self.screen._forward_event(event)
|
||||
elif isinstance(event, events.Key):
|
||||
if not await self.check_bindings(event.key, universal=True):
|
||||
if not await self.check_bindings(event.key, priority=True):
|
||||
forward_target = self.focused or self.screen
|
||||
await forward_target._forward_event(event)
|
||||
else:
|
||||
|
||||
@@ -20,6 +20,8 @@ class NoBinding(Exception):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
"""The configuration of a key binding."""
|
||||
|
||||
key: str
|
||||
"""str: Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
|
||||
action: str
|
||||
@@ -30,15 +32,31 @@ class Binding:
|
||||
"""bool: Show the action in Footer, or False to hide."""
|
||||
key_display: str | None = None
|
||||
"""str | None: How the key should be shown in footer."""
|
||||
universal: bool = False
|
||||
"""bool: Allow forwarding from app to focused widget."""
|
||||
priority: bool | None = None
|
||||
"""bool | None: Is this a priority binding, checked form app down to focused widget?"""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Bindings:
|
||||
"""Manage a set of bindings."""
|
||||
|
||||
def __init__(self, bindings: Iterable[BindingType] | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
bindings: Iterable[BindingType] | None = None,
|
||||
default_priority: bool | None = None,
|
||||
) -> None:
|
||||
"""Initialise a collection of bindings.
|
||||
|
||||
Args:
|
||||
bindings (Iterable[BindingType] | None, optional): An optional set of initial bindings.
|
||||
default_priority (bool | None, optional): The default priority of the bindings.
|
||||
|
||||
Note:
|
||||
The iterable of bindings can contain either a `Binding`
|
||||
instance, or a tuple of 3 values mapping to the first three
|
||||
properties of a `Binding`.
|
||||
"""
|
||||
|
||||
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
|
||||
for binding in bindings:
|
||||
# If it's a tuple of length 3, convert into a Binding first
|
||||
@@ -49,20 +67,20 @@ class Bindings:
|
||||
)
|
||||
binding = Binding(*binding)
|
||||
|
||||
binding_keys = binding.key.split(",")
|
||||
if len(binding_keys) > 1:
|
||||
for key in binding_keys:
|
||||
new_binding = Binding(
|
||||
key=key,
|
||||
# At this point we have a Binding instance, but the key may
|
||||
# be a list of keys, so now we unroll that single Binding
|
||||
# into a (potential) collection of Binding instances.
|
||||
for key in binding.key.split(","):
|
||||
yield Binding(
|
||||
key=key.strip(),
|
||||
action=binding.action,
|
||||
description=binding.description,
|
||||
show=binding.show,
|
||||
key_display=binding.key_display,
|
||||
universal=binding.universal,
|
||||
priority=default_priority
|
||||
if binding.priority is None
|
||||
else binding.priority,
|
||||
)
|
||||
yield new_binding
|
||||
else:
|
||||
yield binding
|
||||
|
||||
self.keys: MutableMapping[str, Binding] = (
|
||||
{binding.key: binding for binding in make_bindings(bindings)}
|
||||
@@ -105,7 +123,7 @@ class Bindings:
|
||||
description: str = "",
|
||||
show: bool = True,
|
||||
key_display: str | None = None,
|
||||
universal: bool = False,
|
||||
priority: bool = False,
|
||||
) -> None:
|
||||
"""Bind keys to an action.
|
||||
|
||||
@@ -115,7 +133,7 @@ class Bindings:
|
||||
description (str, optional): An optional description for the binding.
|
||||
show (bool, optional): A flag to say if the binding should appear in the footer.
|
||||
key_display (str | None, optional): Optional string to display in the footer for the key.
|
||||
universal (bool, optional): Allow forwarding from the app to the focused widget.
|
||||
priority (bool, optional): Is this a priority binding, checked form app down to focused widget?
|
||||
"""
|
||||
all_keys = [key.strip() for key in keys.split(",")]
|
||||
for key in all_keys:
|
||||
@@ -125,7 +143,7 @@ class Bindings:
|
||||
description,
|
||||
show=show,
|
||||
key_display=key_display,
|
||||
universal=universal,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
def get_key(self, key: str) -> Binding:
|
||||
|
||||
@@ -92,6 +92,9 @@ class DOMNode(MessagePump):
|
||||
# Virtual DOM nodes
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = set()
|
||||
|
||||
# Should the content of BINDINGS be treated as priority bindings?
|
||||
PRIORITY_BINDINGS: ClassVar[bool] = False
|
||||
|
||||
# Mapping of key bindings
|
||||
BINDINGS: ClassVar[list[BindingType]] = []
|
||||
|
||||
@@ -225,11 +228,18 @@ class DOMNode(MessagePump):
|
||||
"""
|
||||
bindings: list[Bindings] = []
|
||||
|
||||
# To start with, assume that bindings won't be priority bindings.
|
||||
priority = False
|
||||
|
||||
for base in reversed(cls.__mro__):
|
||||
if issubclass(base, DOMNode):
|
||||
# See if the current class wants to set the bindings as
|
||||
# priority bindings. If it doesn't have that property on the
|
||||
# class, go with what we saw last.
|
||||
priority = base.__dict__.get("PRIORITY_BINDINGS", priority)
|
||||
if not base._inherit_bindings:
|
||||
bindings.clear()
|
||||
bindings.append(Bindings(base.__dict__.get("BINDINGS", [])))
|
||||
bindings.append(Bindings(base.__dict__.get("BINDINGS", []), priority))
|
||||
keys = {}
|
||||
for bindings_ in bindings:
|
||||
keys.update(bindings_.keys)
|
||||
|
||||
@@ -26,6 +26,11 @@ UPDATE_PERIOD: Final[float] = 1 / 120
|
||||
class Screen(Widget):
|
||||
"""A widget for the root of the app."""
|
||||
|
||||
# The screen is a special case and unless a class that inherits from us
|
||||
# says otherwise, all screen-level bindings should be treated as having
|
||||
# priority.
|
||||
PRIORITY_BINDINGS = True
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
|
||||
@@ -6,12 +6,16 @@ from textual.binding import Bindings, Binding, BindingError, NoBinding
|
||||
|
||||
BINDING1 = Binding("a,b", action="action1", description="description1")
|
||||
BINDING2 = Binding("c", action="action2", description="description2")
|
||||
BINDING3 = Binding(" d , e ", action="action3", description="description3")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bindings():
|
||||
yield Bindings([BINDING1, BINDING2])
|
||||
|
||||
@pytest.fixture
|
||||
def more_bindings():
|
||||
yield Bindings([BINDING1, BINDING2, BINDING3])
|
||||
|
||||
def test_bindings_get_key(bindings):
|
||||
assert bindings.get_key("b") == Binding("b", action="action1", description="description1")
|
||||
@@ -19,6 +23,9 @@ def test_bindings_get_key(bindings):
|
||||
with pytest.raises(NoBinding):
|
||||
bindings.get_key("control+meta+alt+shift+super+hyper+t")
|
||||
|
||||
def test_bindings_get_key_spaced_list(more_bindings):
|
||||
assert more_bindings.get_key("d").action == more_bindings.get_key("e").action
|
||||
|
||||
def test_bindings_merge_simple(bindings):
|
||||
left = Bindings([BINDING1])
|
||||
right = Bindings([BINDING2])
|
||||
|
||||
609
tests/test_binding_inheritance.py
Normal file
609
tests/test_binding_inheritance.py
Normal file
@@ -0,0 +1,609 @@
|
||||
"""Tests relating to key binding inheritance.
|
||||
|
||||
In here you'll find some tests for general key binding inheritance, but
|
||||
there is an emphasis on the inheriting of movement key bindings as they (as
|
||||
of the time of writing) hold a special place in the Widget hierarchy of
|
||||
Textual.
|
||||
|
||||
<URL:https://github.com/Textualize/textual/issues/1343> holds much of the
|
||||
background relating to this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
##############################################################################
|
||||
# These are the movement keys within Textual; they kind of have a special
|
||||
# status in that they will get bound to movement-related methods.
|
||||
MOVEMENT_KEYS = ["up", "down", "left", "right", "home", "end", "pageup", "pagedown"]
|
||||
|
||||
##############################################################################
|
||||
# An application with no bindings anywhere.
|
||||
#
|
||||
# The idea of this first little test is that an application that has no
|
||||
# bindings set anywhere, and uses a default screen, should only have the one
|
||||
# binding in place: ctrl+c; it's hard-coded in the app class for now.
|
||||
|
||||
|
||||
class NoBindings(App[None]):
|
||||
"""An app with zero bindings."""
|
||||
|
||||
|
||||
async def test_just_app_no_bindings() -> None:
|
||||
"""An app with no bindings should have no bindings, other than ctrl+c."""
|
||||
async with NoBindings().run_test() as pilot:
|
||||
assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c"]
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An application with a single alpha binding.
|
||||
#
|
||||
# Sticking with just an app and the default screen: this configuration has a
|
||||
# BINDINGS on the app itself, and simply binds the letter a -- in other
|
||||
# words avoiding anything to do with movement keys. The result should be
|
||||
# that we see the letter a, ctrl+c, and nothing else.
|
||||
|
||||
|
||||
class AlphaBinding(App[None]):
|
||||
"""An app with a simple alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
|
||||
|
||||
async def test_just_app_alpha_binding() -> None:
|
||||
"""An app with a single binding should have just the one binding."""
|
||||
async with AlphaBinding().run_test() as pilot:
|
||||
assert sorted(pilot.app._bindings.keys.keys()) == sorted(["ctrl+c", "a"])
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
assert pilot.app._bindings.get_key("a").priority is True
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An application with a single low-priority alpha binding.
|
||||
#
|
||||
# The same as the above, but in this case we're going to, on purpose, lower
|
||||
# the priority of our own bindings, while any define by App itself should
|
||||
# remain the same.
|
||||
|
||||
|
||||
class LowAlphaBinding(App[None]):
|
||||
"""An app with a simple low-priority alpha key binding."""
|
||||
|
||||
PRIORITY_BINDINGS = False
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
|
||||
|
||||
async def test_just_app_low_priority_alpha_binding() -> None:
|
||||
"""An app with a single low-priority binding should have just the one binding."""
|
||||
async with LowAlphaBinding().run_test() as pilot:
|
||||
assert sorted(pilot.app._bindings.keys.keys()) == sorted(["ctrl+c", "a"])
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
assert pilot.app._bindings.get_key("a").priority is False
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A non-default screen with a single alpha key binding.
|
||||
#
|
||||
# There's little point in testing a screen with no bindings added as that's
|
||||
# pretty much the same as an app with a default screen (for the purposes of
|
||||
# these tests). So, let's test a screen with a single alpha-key binding.
|
||||
|
||||
|
||||
class ScreenWithBindings(Screen):
|
||||
"""A screen with a simple alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
|
||||
|
||||
class AppWithScreenThatHasABinding(App[None]):
|
||||
"""An app with no extra bindings but with a custom screen with a binding."""
|
||||
|
||||
SCREENS = {"main": ScreenWithBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_app_screen_with_bindings() -> None:
|
||||
"""Test a screen with a single key binding defined."""
|
||||
async with AppWithScreenThatHasABinding().run_test() as pilot:
|
||||
# The screen will contain all of the movement keys, because it
|
||||
# inherits from Widget. That's fine. Let's check they're there, but
|
||||
# also let's check that they all have a non-priority binding.
|
||||
assert all(
|
||||
pilot.app.screen._bindings.get_key(key).priority is False
|
||||
for key in MOVEMENT_KEYS
|
||||
)
|
||||
# Let's also check that the 'a' key is there, and it *is* a priority
|
||||
# binding.
|
||||
assert pilot.app.screen._bindings.get_key("a").priority is True
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A non-default screen with a single low-priority alpha key binding.
|
||||
#
|
||||
# As above, but because Screen sets akk keys as high priority by default, we
|
||||
# want to be sure that if we set our keys in our subclass as low priority as
|
||||
# default, they come through as such.
|
||||
|
||||
|
||||
class ScreenWithLowBindings(Screen):
|
||||
"""A screen with a simple low-priority alpha key binding."""
|
||||
|
||||
PRIORITY_BINDINGS = False
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
|
||||
|
||||
class AppWithScreenThatHasALowBinding(App[None]):
|
||||
"""An app with no extra bindings but with a custom screen with a low-priority binding."""
|
||||
|
||||
SCREENS = {"main": ScreenWithLowBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_app_screen_with_low_bindings() -> None:
|
||||
"""Test a screen with a single low-priority key binding defined."""
|
||||
async with AppWithScreenThatHasALowBinding().run_test() as pilot:
|
||||
# Screens inherit from Widget which means they get movement keys
|
||||
# too, so let's ensure they're all in there, along with our own key,
|
||||
# and that everyone is low-priority.
|
||||
assert all(
|
||||
pilot.app.screen._bindings.get_key(key).priority is False
|
||||
for key in ["a", *MOVEMENT_KEYS]
|
||||
)
|
||||
|
||||
|
||||
##############################################################################
|
||||
# From here on in we're going to start simulating keystrokes to ensure that
|
||||
# any bindings that are in place actually fire the correct actions. To help
|
||||
# with this let's build a simple key/binding/action recorder base app.
|
||||
|
||||
|
||||
class AppKeyRecorder(App[None]):
|
||||
"""Base application class that can be used to record keystrokes."""
|
||||
|
||||
ALPHAS = "abcxyz"
|
||||
"""str: The alpha keys to test against."""
|
||||
|
||||
ALL_KEYS = [*ALPHAS, *MOVEMENT_KEYS]
|
||||
"""list[str]: All the test keys."""
|
||||
|
||||
@staticmethod
|
||||
def make_bindings(action_prefix: str = "") -> list[Binding]:
|
||||
"""Make the binding list for testing an app.
|
||||
|
||||
Args:
|
||||
action_prefix (str, optional): An optional prefix for the action name.
|
||||
|
||||
Returns:
|
||||
list[Binding]: The resulting list of bindings.
|
||||
"""
|
||||
return [
|
||||
Binding(key, f"{action_prefix}record('{key}')", key)
|
||||
for key in [*AppKeyRecorder.ALPHAS, *MOVEMENT_KEYS]
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialise the recording app."""
|
||||
super().__init__()
|
||||
self.pressed_keys: list[str] = []
|
||||
|
||||
async def action_record(self, key: str) -> None:
|
||||
"""Record a key, as used from a binding.
|
||||
|
||||
Args:
|
||||
key (str): The name of the key to record.
|
||||
"""
|
||||
self.pressed_keys.append(key)
|
||||
|
||||
def all_recorded(self, marker_prefix: str = "") -> None:
|
||||
"""Were all the bindings recorded from the presses?
|
||||
|
||||
Args:
|
||||
marker_prefix (str, optional): An optional prefix for the result markers.
|
||||
"""
|
||||
assert self.pressed_keys == [f"{marker_prefix}{key}" for key in self.ALL_KEYS]
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An app with bindings for movement keys.
|
||||
#
|
||||
# Having gone through various permutations of testing for what bindings are
|
||||
# seen to be in place, we now move on to adding bindings, invoking them and
|
||||
# seeing what happens. First off let's start with an application that has
|
||||
# bindings, both for an alpha key, and also for all of the movement keys.
|
||||
|
||||
|
||||
class AppWithMovementKeysBound(AppKeyRecorder):
|
||||
"""An application with bindings."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings()
|
||||
|
||||
|
||||
async def test_pressing_alpha_on_app() -> None:
|
||||
"""Test that pressing the alpha key, when it's bound on the app, results in an action fire."""
|
||||
async with AppWithMovementKeysBound().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALPHAS)
|
||||
await pilot.pause(2 / 100)
|
||||
assert pilot.app.pressed_keys == [*AppKeyRecorder.ALPHAS]
|
||||
|
||||
|
||||
async def test_pressing_movement_keys_app() -> None:
|
||||
"""Test that pressing the movement keys, when they're bound on the app, results in an action fire."""
|
||||
async with AppWithMovementKeysBound().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded()
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An app with a focused child widget with bindings.
|
||||
#
|
||||
# Now let's spin up an application, using the default screen, where the app
|
||||
# itself is composing in a widget that can have, and has, focus. The widget
|
||||
# also has bindings for all of the test keys. That child widget should be
|
||||
# able to handle all of the test keys on its own and nothing else should
|
||||
# grab them.
|
||||
|
||||
|
||||
class FocusableWidgetWithBindings(Static, can_focus=True):
|
||||
"""A widget that has its own bindings for the movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("local_")
|
||||
|
||||
async def action_local_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"locally_{key}")
|
||||
|
||||
|
||||
class AppWithWidgetWithBindings(AppKeyRecorder):
|
||||
"""A test app that composes with a widget that has movement bindings."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithBindings()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithBindings).focus()
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings() -> None:
|
||||
"""A focused child widget with movement bindings should handle its own actions."""
|
||||
async with AppWithWidgetWithBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("locally_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget within a screen that handles bindings.
|
||||
#
|
||||
# Similar to the previous test, here we're wrapping an app around a
|
||||
# non-default screen, which in turn wraps a widget that can has, and will
|
||||
# have, focus. The difference here however is that the screen has the
|
||||
# bindings. What we should expect to see is that the bindings don't fire on
|
||||
# the widget (it has none) and instead get caught by the screen.
|
||||
|
||||
|
||||
class FocusableWidgetWithNoBindings(Static, can_focus=True):
|
||||
"""A widget that can receive focus but has no bindings."""
|
||||
|
||||
|
||||
class ScreenWithMovementBindings(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithNoBindings()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindings).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetNoBindings(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetNoBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget within a container within a screen that handles bindings.
|
||||
#
|
||||
# Similar again to the previous test, here we're wrapping an app around a
|
||||
# non-default screen, which in turn wraps a container which wraps a widget
|
||||
# that can have, and will have, focus. The issue here is that if the
|
||||
# container isn't scrolling, especially if it's set up to just wrap a widget
|
||||
# and do nothing else, it should not rob the screen of the binding hits.
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsAndContainerAroundWidget(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Container(FocusableWidgetWithNoBindings())
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindings).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWrappedWidgetNoBindings(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_contained_focused_child_widget_with_movement_bindings_on_screen() -> None:
|
||||
"""A contained focused child widget, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWrappedWidgetNoBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with bindings but no inheriting of bindings, on app.
|
||||
#
|
||||
# Now we move on to testing inherit_bindings. To start with we go back to an
|
||||
# app with a default screen, with the app itself composing in a widget that
|
||||
# can and will have focus, which has bindings for all the test keys, and
|
||||
# crucially has inherit_bindings set to False.
|
||||
#
|
||||
# We should expect to see all of the test keys recorded post-press.
|
||||
|
||||
|
||||
class WidgetWithBindingsNoInherit(Static, can_focus=True, inherit_bindings=False):
|
||||
"""A widget that has its own bindings for the movement keys, no binding inheritance."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("local_")
|
||||
|
||||
async def action_local_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"locally_{key}")
|
||||
|
||||
|
||||
class AppWithWidgetWithBindingsNoInherit(AppKeyRecorder):
|
||||
"""A test app that composes with a widget that has movement bindings without binding inheritance."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield WidgetWithBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(WidgetWithBindingsNoInherit).focus()
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings_no_inherit() -> None:
|
||||
"""A focused child widget with movement bindings and inherit_bindings=False should handle its own actions."""
|
||||
async with AppWithWidgetWithBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("locally_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with no bindings and no inheriting of bindings, on screen.
|
||||
#
|
||||
# Now let's test with a widget that can and will have focus, which has no
|
||||
# bindings, and which won't inherit bindings either. The bindings we're
|
||||
# going to test are moved up to the screen. We should expect to see all of
|
||||
# the test keys not be consumed by the focused widget, but instead they
|
||||
# should make it up to the screen.
|
||||
#
|
||||
# NOTE: no bindings are declared for the widget, which is different from
|
||||
# zero bindings declared.
|
||||
|
||||
|
||||
class FocusableWidgetWithNoBindingsNoInherit(
|
||||
Static, can_focus=True, inherit_bindings=False
|
||||
):
|
||||
"""A widget that can receive focus but has no bindings and doesn't inherit bindings."""
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsNoInheritChild(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithNoBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindingsNoInherit).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetNoBindingsNoInherit(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings, child no-inherit."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindingsNoInheritChild}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, that doesn't inherit bindings, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetNoBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with zero bindings declared, but no inheriting of
|
||||
# bindings, on screen.
|
||||
#
|
||||
# Now let's test with a widget that can and will have focus, which has zero
|
||||
# (an empty collection of) bindings, and which won't inherit bindings
|
||||
# either. The bindings we're going to test are moved up to the screen. We
|
||||
# should expect to see all of the test keys not be consumed by the focused
|
||||
# widget, but instead they should make it up to the screen.
|
||||
#
|
||||
# NOTE: zero bindings are declared for the widget, which is different from
|
||||
# no bindings declared.
|
||||
|
||||
|
||||
class FocusableWidgetWithEmptyBindingsNoInherit(
|
||||
Static, can_focus=True, inherit_bindings=False
|
||||
):
|
||||
"""A widget that can receive focus but has empty bindings and doesn't inherit bindings."""
|
||||
|
||||
BINDINGS = []
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsNoInheritEmptyChild(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithEmptyBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithEmptyBindingsNoInherit).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings, child no-inherit."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindingsNoInheritEmptyChild}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, that doesn't inherit bindings and sets BINDINGS empty, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# Testing priority of overlapping bindings.
|
||||
#
|
||||
# Here we we'll have an app, screen, and a focused widget, along with a
|
||||
# combination of overlapping bindings, each with different forms of
|
||||
# priority, so we can check who wins where.
|
||||
#
|
||||
# Here are the permutations tested, with the expected winner:
|
||||
#
|
||||
# |-----|----------|----------|----------|--------|
|
||||
# | Key | App | Screen | Widget | Winner |
|
||||
# |-----|----------|----------|----------|--------|
|
||||
# | 0 | | | | Widget |
|
||||
# | A | Priority | | | App |
|
||||
# | B | | Priority | | Screen |
|
||||
# | C | | | Priority | Widget |
|
||||
# | D | Priority | Priority | | App |
|
||||
# | E | Priority | | Priority | App |
|
||||
# | F | | Priority | Priority | Screen |
|
||||
|
||||
|
||||
class PriorityOverlapWidget(Static, can_focus=True):
|
||||
"""A focusable widget with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "app.record('widget_0')", "0", priority=False),
|
||||
Binding("a", "app.record('widget_a')", "a", priority=False),
|
||||
Binding("b", "app.record('widget_b')", "b", priority=False),
|
||||
Binding("c", "app.record('widget_c')", "c", priority=True),
|
||||
Binding("d", "app.record('widget_d')", "d", priority=False),
|
||||
Binding("e", "app.record('widget_e')", "e", priority=True),
|
||||
Binding("f", "app.record('widget_f')", "f", priority=True),
|
||||
]
|
||||
|
||||
|
||||
class PriorityOverlapScreen(Screen):
|
||||
"""A screen with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "app.record('screen_0')", "0", priority=False),
|
||||
Binding("a", "app.record('screen_a')", "a", priority=False),
|
||||
Binding("b", "app.record('screen_b')", "b", priority=True),
|
||||
Binding("c", "app.record('screen_c')", "c", priority=False),
|
||||
Binding("d", "app.record('screen_d')", "c", priority=True),
|
||||
Binding("e", "app.record('screen_e')", "e", priority=False),
|
||||
Binding("f", "app.record('screen_f')", "f", priority=True),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield PriorityOverlapWidget()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(PriorityOverlapWidget).focus()
|
||||
|
||||
class PriorityOverlapApp(AppKeyRecorder):
|
||||
"""An application with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "record('app_0')", "0", priority=False),
|
||||
Binding("a", "record('app_a')", "a", priority=True),
|
||||
Binding("b", "record('app_b')", "b", priority=False),
|
||||
Binding("c", "record('app_c')", "c", priority=False),
|
||||
Binding("d", "record('app_d')", "c", priority=True),
|
||||
Binding("e", "record('app_e')", "e", priority=True),
|
||||
Binding("f", "record('app_f')", "f", priority=False),
|
||||
]
|
||||
|
||||
SCREENS = {"main": PriorityOverlapScreen}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_overlapping_priority_bindings() -> None:
|
||||
"""Test an app stack with overlapping bindings."""
|
||||
async with PriorityOverlapApp().run_test() as pilot:
|
||||
await pilot.press(*"0abcdef")
|
||||
await pilot.pause(2 / 100)
|
||||
assert pilot.app.pressed_keys == [
|
||||
"widget_0",
|
||||
"app_a",
|
||||
"screen_b",
|
||||
"widget_c",
|
||||
"app_d",
|
||||
"app_e",
|
||||
"screen_f",
|
||||
]
|
||||
Reference in New Issue
Block a user