Keymaps (#5038)
This commit is contained in:
parent
521fdcf3df
commit
d472cb582e
9 changed files with 828 additions and 44 deletions
|
|
@ -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/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for keymaps (user configurable key bindings) https://github.com/Textualize/textual/pull/5038
|
||||
|
||||
## [0.81.0] - 2024-09-25
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ from textual._wait import wait_for_idle
|
|||
from textual.actions import ActionParseResult, SkipAction
|
||||
from textual.await_complete import AwaitComplete
|
||||
from textual.await_remove import AwaitRemove
|
||||
from textual.binding import Binding, BindingsMap, BindingType
|
||||
from textual.binding import Binding, BindingsMap, BindingType, Keymap
|
||||
from textual.command import CommandPalette, Provider
|
||||
from textual.css.errors import StylesheetError
|
||||
from textual.css.query import NoMatches
|
||||
|
|
@ -659,6 +659,8 @@ class App(Generic[ReturnType], DOMNode):
|
|||
|
||||
self._registry: WeakSet[DOMNode] = WeakSet()
|
||||
|
||||
self._keymap: Keymap = {}
|
||||
|
||||
# Sensitivity on X is double the sensitivity on Y to account for
|
||||
# cells being twice as tall as wide
|
||||
self.scroll_sensitivity_x: float = 4.0
|
||||
|
|
@ -754,8 +756,8 @@ class App(Generic[ReturnType], DOMNode):
|
|||
happens.
|
||||
"""
|
||||
|
||||
# Size of previous inline update
|
||||
self._previous_inline_height: int | None = None
|
||||
"""Size of previous inline update."""
|
||||
|
||||
if self.ENABLE_COMMAND_PALETTE:
|
||||
for _key, binding in self._bindings:
|
||||
|
|
@ -3422,6 +3424,51 @@ class App(Generic[ReturnType], DOMNode):
|
|||
return True
|
||||
return False
|
||||
|
||||
def set_keymap(self, keymap: Keymap) -> None:
|
||||
"""Set the keymap, a mapping of binding IDs to key strings.
|
||||
|
||||
Bindings in the keymap are used to override default key bindings,
|
||||
i.e. those defined in `BINDINGS` class variables.
|
||||
|
||||
Bindings with IDs that are present in the keymap will have
|
||||
their key string replaced with the value from the keymap.
|
||||
|
||||
Args:
|
||||
keymap: A mapping of binding IDs to key strings.
|
||||
"""
|
||||
self._keymap = keymap
|
||||
|
||||
def update_keymap(self, keymap: Keymap) -> None:
|
||||
"""Update the App's keymap, merging with `keymap`.
|
||||
|
||||
If a Binding ID exists in both the App's keymap and the `keymap`
|
||||
argument, the `keymap` argument takes precedence.
|
||||
|
||||
Args:
|
||||
keymap: A mapping of binding IDs to key strings.
|
||||
"""
|
||||
self._keymap = {**self._keymap, **keymap}
|
||||
|
||||
def handle_bindings_clash(
|
||||
self, clashed_bindings: set[Binding], node: DOMNode
|
||||
) -> None:
|
||||
"""Handle a clash between bindings.
|
||||
|
||||
Bindings clashes are likely due to users setting conflicting
|
||||
keys via their keymap.
|
||||
|
||||
This method is intended to be overridden by subclasses.
|
||||
|
||||
Textual will call this each time a clash is encountered -
|
||||
which may be on each keypress if a clashing widget is focused
|
||||
or is in the bindings chain.
|
||||
|
||||
Args:
|
||||
clashed_bindings: The bindings that are clashing.
|
||||
node: The node that has the clashing bindings.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_event(self, event: events.Event) -> None:
|
||||
# Handle input events that haven't been forwarded
|
||||
# If the event has been forwarded it may have bubbled up back to the App
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ See [bindings](/guide/input#bindings) in the guide for details.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
|
||||
from typing import TYPE_CHECKING, Iterable, Iterator, Mapping, NamedTuple
|
||||
|
||||
import rich.repr
|
||||
|
||||
|
|
@ -20,6 +21,22 @@ if TYPE_CHECKING:
|
|||
from textual.dom import DOMNode
|
||||
|
||||
BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]"
|
||||
"""The possible types of a binding found in the `BINDINGS` class variable."""
|
||||
|
||||
BindingIDString: TypeAlias = str
|
||||
"""The ID of a Binding defined somewhere in the application.
|
||||
|
||||
Corresponds to the `id` parameter of the `Binding` class.
|
||||
"""
|
||||
|
||||
KeyString: TypeAlias = str
|
||||
"""A string that represents a key binding.
|
||||
|
||||
For example, "x", "ctrl+i", "ctrl+shift+a", "ctrl+j,space,x", etc.
|
||||
"""
|
||||
|
||||
Keymap = Mapping[BindingIDString, KeyString]
|
||||
"""A mapping of binding IDs to key strings, used for overriding default key bindings."""
|
||||
|
||||
|
||||
class BindingError(Exception):
|
||||
|
|
@ -47,12 +64,24 @@ class Binding:
|
|||
show: bool = True
|
||||
"""Show the action in Footer, or False to hide."""
|
||||
key_display: str | None = None
|
||||
"""How the key should be shown in footer."""
|
||||
"""How the key should be shown in footer.
|
||||
|
||||
If None, the display of the key will use the result of `App.get_key_display`.
|
||||
|
||||
If overridden in a keymap then this value is ignored.
|
||||
"""
|
||||
priority: bool = False
|
||||
"""Enable priority binding for this key."""
|
||||
tooltip: str = ""
|
||||
"""Optional tooltip to show in footer."""
|
||||
|
||||
id: str | None = None
|
||||
"""ID of the binding. Intended to be globally unique, but uniqueness is not enforced.
|
||||
|
||||
If specified in the App's keymap then Textual will use this ID to lookup the binding,
|
||||
and substitute the `key` property of the Binding with the key specified in the keymap.
|
||||
"""
|
||||
|
||||
def parse_key(self) -> tuple[list[str], str]:
|
||||
"""Parse a key in to a list of modifiers, and the actual key.
|
||||
|
||||
|
|
@ -62,6 +91,65 @@ class Binding:
|
|||
*modifiers, key = self.key.split("+")
|
||||
return modifiers, key
|
||||
|
||||
def with_key(self, key: str, key_display: str | None = None) -> Binding:
|
||||
"""Return a new binding with the key and key_display set to the specified values.
|
||||
|
||||
Args:
|
||||
key: The new key to set.
|
||||
key_display: The new key display to set.
|
||||
|
||||
Returns:
|
||||
A new binding with the key set to the specified value.
|
||||
"""
|
||||
return dataclasses.replace(self, key=key, key_display=key_display)
|
||||
|
||||
@classmethod
|
||||
def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]:
|
||||
"""Convert a list of BindingType (the types that can be specified in BINDINGS)
|
||||
into an Iterable[Binding].
|
||||
|
||||
Compound bindings like "j,down" will be expanded into 2 Binding instances.
|
||||
|
||||
Args:
|
||||
bindings: An iterable of BindingType.
|
||||
|
||||
Returns:
|
||||
An iterable of Binding.
|
||||
"""
|
||||
bindings = list(bindings)
|
||||
for binding in bindings:
|
||||
# If it's a tuple of length 3, convert into a Binding first
|
||||
if isinstance(binding, tuple):
|
||||
if len(binding) not in (2, 3):
|
||||
raise BindingError(
|
||||
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
|
||||
)
|
||||
# `binding` is a tuple of 2 or 3 values at this point
|
||||
binding = Binding(*binding) # type: ignore[reportArgumentType]
|
||||
|
||||
# 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(","):
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise InvalidBinding(
|
||||
f"Can not bind empty string in {binding.key!r}"
|
||||
)
|
||||
if len(key) == 1:
|
||||
key = _character_to_key(key)
|
||||
|
||||
yield Binding(
|
||||
key=key,
|
||||
action=binding.action,
|
||||
description=binding.description,
|
||||
show=bool(binding.description and binding.show),
|
||||
key_display=binding.key_display,
|
||||
priority=binding.priority,
|
||||
tooltip=binding.tooltip,
|
||||
id=binding.id,
|
||||
)
|
||||
|
||||
|
||||
class ActiveBinding(NamedTuple):
|
||||
"""Information about an active binding (returned from [active_bindings][textual.screen.Screen.active_bindings])."""
|
||||
|
|
@ -95,41 +183,10 @@ class BindingsMap:
|
|||
properties of a `Binding`.
|
||||
"""
|
||||
|
||||
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
|
||||
bindings = list(bindings)
|
||||
for binding in bindings:
|
||||
# If it's a tuple of length 3, convert into a Binding first
|
||||
if isinstance(binding, tuple):
|
||||
if len(binding) not in (2, 3):
|
||||
raise BindingError(
|
||||
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
|
||||
)
|
||||
# `binding` is a tuple of 2 or 3 values at this point
|
||||
binding = Binding(*binding) # type: ignore[reportArgumentType]
|
||||
|
||||
# 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(","):
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise InvalidBinding(
|
||||
f"Can not bind empty string in {binding.key!r}"
|
||||
)
|
||||
if len(key) == 1:
|
||||
key = _character_to_key(key)
|
||||
yield Binding(
|
||||
key=key,
|
||||
action=binding.action,
|
||||
description=binding.description,
|
||||
show=bool(binding.description and binding.show),
|
||||
key_display=binding.key_display,
|
||||
priority=binding.priority,
|
||||
tooltip=binding.tooltip,
|
||||
)
|
||||
|
||||
self.key_to_bindings: dict[str, list[Binding]] = {}
|
||||
for binding in make_bindings(bindings or {}):
|
||||
"""Mapping of key (e.g. "ctrl+a") to list of bindings for that key."""
|
||||
|
||||
for binding in Binding.make_bindings(bindings or {}):
|
||||
self.key_to_bindings.setdefault(binding.key, []).append(binding)
|
||||
|
||||
def _add_binding(self, binding: Binding) -> None:
|
||||
|
|
@ -193,6 +250,71 @@ class BindingsMap:
|
|||
keys.setdefault(key, []).extend(key_bindings)
|
||||
return BindingsMap.from_keys(keys)
|
||||
|
||||
def apply_keymap(self, keymap: Keymap) -> KeymapApplyResult:
|
||||
"""Replace bindings for keys that are present in `keymap`.
|
||||
|
||||
Preserves existing bindings for keys that are not in `keymap`.
|
||||
|
||||
Args:
|
||||
keymap: A keymap to overlay.
|
||||
|
||||
Returns:
|
||||
KeymapApplyResult: The result of applying the keymap, including any clashed bindings.
|
||||
"""
|
||||
clashed_bindings: set[Binding] = set()
|
||||
new_bindings: dict[str, list[Binding]] = {}
|
||||
|
||||
key_to_bindings = list(self.key_to_bindings.items())
|
||||
for key, bindings in key_to_bindings:
|
||||
for binding in bindings:
|
||||
binding_id = binding.id
|
||||
if binding_id is None:
|
||||
# Bindings without an ID are irrelevant when applying a keymap
|
||||
continue
|
||||
|
||||
# If the keymap has an override for this binding ID
|
||||
if keymap_key_string := keymap.get(binding_id):
|
||||
keymap_keys = keymap_key_string.split(",")
|
||||
|
||||
# Remove the old binding
|
||||
for key, key_bindings in key_to_bindings:
|
||||
key = key.strip()
|
||||
if any(binding.id == binding_id for binding in key_bindings):
|
||||
if key in self.key_to_bindings:
|
||||
del self.key_to_bindings[key]
|
||||
|
||||
for keymap_key in keymap_keys:
|
||||
if (
|
||||
keymap_key in self.key_to_bindings
|
||||
or keymap_key in new_bindings
|
||||
):
|
||||
# The key is already mapped either by default or by the keymap,
|
||||
# so there's a clash unless the existing binding is being rebound
|
||||
# to a different key.
|
||||
clashing_bindings = self.key_to_bindings.get(
|
||||
keymap_key, []
|
||||
) + new_bindings.get(keymap_key, [])
|
||||
for clashed_binding in clashing_bindings:
|
||||
# If the existing binding is not being rebound, it's a clash
|
||||
if not (
|
||||
clashed_binding.id
|
||||
and keymap.get(clashed_binding.id)
|
||||
!= clashed_binding.key
|
||||
):
|
||||
clashed_bindings.add(clashed_binding)
|
||||
|
||||
if keymap_key in self.key_to_bindings:
|
||||
del self.key_to_bindings[keymap_key]
|
||||
|
||||
for keymap_key in keymap_keys:
|
||||
new_bindings.setdefault(keymap_key, []).append(
|
||||
binding.with_key(key=keymap_key, key_display=None)
|
||||
)
|
||||
|
||||
# Update the key_to_bindings with the new bindings
|
||||
self.key_to_bindings.update(new_bindings)
|
||||
return KeymapApplyResult(clashed_bindings)
|
||||
|
||||
@property
|
||||
def shown_keys(self) -> list[Binding]:
|
||||
"""A list of bindings for shown keys."""
|
||||
|
|
@ -252,3 +374,10 @@ class BindingsMap:
|
|||
return self.key_to_bindings[key]
|
||||
except KeyError:
|
||||
raise NoBinding(f"No binding for {key}") from None
|
||||
|
||||
|
||||
class KeymapApplyResult(NamedTuple):
|
||||
"""The result of applying a keymap."""
|
||||
|
||||
clashed_bindings: set[Binding]
|
||||
"""A list of bindings that were clashed and replaced by the keymap."""
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ class DOMNode(MessagePump):
|
|||
self._has_hover_style: bool = False
|
||||
self._has_focus_within: bool = False
|
||||
self._reactive_connect: (
|
||||
dict[str, tuple[MessagePump, Reactive | object]] | None
|
||||
dict[str, tuple[MessagePump, Reactive[object] | object]] | None
|
||||
) = None
|
||||
self._pruning = False
|
||||
self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024)
|
||||
|
|
@ -620,12 +620,13 @@ class DOMNode(MessagePump):
|
|||
base.__dict__.get("BINDINGS", []),
|
||||
)
|
||||
)
|
||||
|
||||
keys: dict[str, list[Binding]] = {}
|
||||
for bindings_ in bindings:
|
||||
for key, key_bindings in bindings_.key_to_bindings.items():
|
||||
keys[key] = key_bindings
|
||||
|
||||
new_bindings = BindingsMap().from_keys(keys)
|
||||
new_bindings = BindingsMap.from_keys(keys)
|
||||
return new_bindings
|
||||
|
||||
def _post_register(self, app: App) -> None:
|
||||
|
|
|
|||
|
|
@ -332,8 +332,8 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||
focused = self.focused
|
||||
if focused is not None and focused.loading:
|
||||
focused = None
|
||||
namespace_bindings: list[tuple[DOMNode, BindingsMap]]
|
||||
|
||||
namespace_bindings: list[tuple[DOMNode, BindingsMap]]
|
||||
if focused is None:
|
||||
namespace_bindings = [
|
||||
(self, self._bindings.copy()),
|
||||
|
|
@ -351,9 +351,19 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||
check_consume_key = filter_namespace.check_consume_key
|
||||
for key in list(bindings_map.key_to_bindings):
|
||||
if check_consume_key(key, key_to_character(key)):
|
||||
# If the widget consumes the key (e.g. like an Input widget),
|
||||
# then remove the key from the bindings map.
|
||||
del bindings_map.key_to_bindings[key]
|
||||
|
||||
filter_namespaces.append(namespace)
|
||||
|
||||
keymap = self.app._keymap
|
||||
for namespace, bindings_map in namespace_bindings:
|
||||
if keymap:
|
||||
result = bindings_map.apply_keymap(keymap)
|
||||
if result.clashed_bindings:
|
||||
self.app.handle_bindings_clash(result.clashed_bindings, namespace)
|
||||
|
||||
return namespace_bindings
|
||||
|
||||
@property
|
||||
|
|
@ -378,15 +388,17 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||
A map of keys to a tuple containing (NAMESPACE, BINDING, ENABLED).
|
||||
"""
|
||||
bindings_map: dict[str, ActiveBinding] = {}
|
||||
app = self.app
|
||||
for namespace, bindings in self._modal_binding_chain:
|
||||
for key, binding in bindings:
|
||||
# This will call the nodes `check_action` method.
|
||||
action_state = self.app._check_action_state(binding.action, namespace)
|
||||
action_state = app._check_action_state(binding.action, namespace)
|
||||
if action_state is False:
|
||||
# An action_state of False indicates the action is disabled and not shown
|
||||
# Note that None has a different meaning, which is why there is an `is False`
|
||||
# rather than a truthy check.
|
||||
continue
|
||||
|
||||
enabled = bool(action_state)
|
||||
if existing_key_and_binding := bindings_map.get(key):
|
||||
# This key has already been bound
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 30 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 31 KiB |
|
|
@ -2,12 +2,13 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from tests.snapshot_tests.language_snippets import SNIPPETS
|
||||
from textual import events
|
||||
from textual import events, on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, Keymap
|
||||
from textual.containers import Vertical
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical, VerticalScroll
|
||||
from textual.pilot import Pilot
|
||||
|
|
@ -1993,6 +1994,84 @@ def test_disabled(snap_compare):
|
|||
assert snap_compare(app)
|
||||
|
||||
|
||||
def test_keymap_bindings_display_footer_and_help_panel(snap_compare):
|
||||
"""Bindings overridden by the Keymap are shown as expected in the Footer
|
||||
and help panel. Testing that the keys work as expected is done elsewhere.
|
||||
|
||||
Footer should show bindings `k` to Increment, and `down` to Decrement.
|
||||
|
||||
Key panel should show bindings `k, plus` to increment,
|
||||
and `down, minus, j` to decrement.
|
||||
|
||||
"""
|
||||
|
||||
class Counter(App[None]):
|
||||
BINDINGS = [
|
||||
Binding(
|
||||
key="i,up",
|
||||
action="increment",
|
||||
description="Increment",
|
||||
id="app.increment",
|
||||
),
|
||||
Binding(
|
||||
key="d,down",
|
||||
action="decrement",
|
||||
description="Decrement",
|
||||
id="app.decrement",
|
||||
),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("Counter")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.action_show_help_panel()
|
||||
self.set_keymap(
|
||||
{
|
||||
"app.increment": "k,plus",
|
||||
"app.decrement": "down,minus,j",
|
||||
}
|
||||
)
|
||||
|
||||
assert snap_compare(Counter())
|
||||
|
||||
|
||||
def test_keymap_bindings_key_display(snap_compare):
|
||||
"""If a default binding in `BINDINGS` has a key_display, it should be reset
|
||||
when that binding is overridden by a Keymap.
|
||||
|
||||
The key_display should be taken from `App.get_key_display`, so in this case
|
||||
it should be "THIS IS CORRECT" in the Footer and help panel, not "INCORRECT".
|
||||
"""
|
||||
|
||||
class MyApp(App[None]):
|
||||
BINDINGS = [
|
||||
Binding(
|
||||
key="i,up",
|
||||
action="increment",
|
||||
description="Increment",
|
||||
id="app.increment",
|
||||
key_display="INCORRECT",
|
||||
),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("Check the footer and help panel")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.action_show_help_panel()
|
||||
self.set_keymap({"app.increment": "k,plus,j,l"})
|
||||
|
||||
def get_key_display(self, binding: Binding) -> str:
|
||||
if binding.id == "app.increment":
|
||||
return "correct"
|
||||
return super().get_key_display(binding)
|
||||
|
||||
assert snap_compare(MyApp())
|
||||
|
||||
|
||||
def test_missing_new_widgets(snap_compare):
|
||||
"""Regression test for https://github.com/Textualize/textual/issues/5024"""
|
||||
|
||||
|
|
|
|||
194
tests/test_keymap.py
Normal file
194
tests/test_keymap.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, Keymap
|
||||
from textual.dom import DOMNode
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
class Counter(App[None]):
|
||||
BINDINGS = [
|
||||
Binding(key="i,up", action="increment", id="app.increment"),
|
||||
Binding(key="d,down", action="decrement", id="app.decrement"),
|
||||
]
|
||||
|
||||
def __init__(self, keymap: Keymap, *args: Any, **kwargs: Any):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.count = 0
|
||||
self.clashed_bindings: set[Binding] | None = None
|
||||
self.clashed_node: DOMNode | None = None
|
||||
self.keymap = keymap
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("foo")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.set_keymap(self.keymap)
|
||||
|
||||
def action_increment(self) -> None:
|
||||
self.count += 1
|
||||
|
||||
def action_decrement(self) -> None:
|
||||
self.count -= 1
|
||||
|
||||
def handle_bindings_clash(
|
||||
self, clashed_bindings: set[Binding], node: DOMNode
|
||||
) -> None:
|
||||
self.clashed_bindings = clashed_bindings
|
||||
self.clashed_node = node
|
||||
|
||||
|
||||
async def test_keymap_default_binding_replaces_old_binding():
|
||||
app = Counter({"app.increment": "right,k"})
|
||||
async with app.run_test() as pilot:
|
||||
# The original bindings are removed - action not called.
|
||||
await pilot.press("i", "up")
|
||||
assert app.count == 0
|
||||
|
||||
# The new bindings are active and call the action.
|
||||
await pilot.press("right", "k")
|
||||
assert app.count == 2
|
||||
|
||||
|
||||
async def test_keymap_sends_message_when_clash():
|
||||
app = Counter({"app.increment": "d"})
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("d")
|
||||
assert app.clashed_bindings is not None
|
||||
assert len(app.clashed_bindings) == 1
|
||||
clash = app.clashed_bindings.pop()
|
||||
assert app.clashed_node is app
|
||||
assert clash.key == "d"
|
||||
assert clash.action == "increment"
|
||||
assert clash.id == "app.increment"
|
||||
|
||||
|
||||
async def test_keymap_with_unknown_id_is_noop():
|
||||
app = Counter({"this.is.an.unknown.id": "d"})
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("d")
|
||||
assert app.count == -1
|
||||
|
||||
|
||||
async def test_keymap_inherited_bindings_same_id():
|
||||
"""When a child widget inherits from a parent widget, if they have
|
||||
a binding with the same ID, then both parent and child bindings will
|
||||
be overridden by the keymap (assuming the keymap has a mapping with the
|
||||
same ID)."""
|
||||
|
||||
parent_counter = 0
|
||||
child_counter = 0
|
||||
|
||||
class Parent(Widget, can_focus=True):
|
||||
BINDINGS = [
|
||||
Binding(key="x", action="increment", id="increment"),
|
||||
]
|
||||
|
||||
def action_increment(self) -> None:
|
||||
nonlocal parent_counter
|
||||
parent_counter += 1
|
||||
|
||||
class Child(Parent):
|
||||
BINDINGS = [
|
||||
Binding(key="x", action="increment", id="increment"),
|
||||
]
|
||||
|
||||
def action_increment(self) -> None:
|
||||
nonlocal child_counter
|
||||
child_counter += 1
|
||||
|
||||
class MyApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Parent()
|
||||
yield Child()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.set_keymap({"increment": "i"})
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
# Default binding is unbound due to keymap.
|
||||
await pilot.press("x")
|
||||
assert parent_counter == 0
|
||||
assert child_counter == 0
|
||||
|
||||
# New binding is active, parent is focused - action called.
|
||||
await pilot.press("i")
|
||||
assert parent_counter == 1
|
||||
assert child_counter == 0
|
||||
|
||||
# Tab to focus the child.
|
||||
await pilot.press("tab")
|
||||
|
||||
# Default binding results in no change.
|
||||
await pilot.press("x")
|
||||
assert parent_counter == 1
|
||||
assert child_counter == 0
|
||||
|
||||
# New binding is active, child is focused - action called.
|
||||
await pilot.press("i")
|
||||
assert parent_counter == 1
|
||||
assert child_counter == 1
|
||||
|
||||
|
||||
async def test_keymap_child_with_different_id_overridden():
|
||||
"""Ensures that overriding a parent binding doesn't influence a child
|
||||
binding with a different ID."""
|
||||
|
||||
parent_counter = 0
|
||||
child_counter = 0
|
||||
|
||||
class Parent(Widget, can_focus=True):
|
||||
BINDINGS = [
|
||||
Binding(key="x", action="increment", id="parent.increment"),
|
||||
]
|
||||
|
||||
def action_increment(self) -> None:
|
||||
nonlocal parent_counter
|
||||
parent_counter += 1
|
||||
|
||||
class Child(Parent):
|
||||
BINDINGS = [
|
||||
Binding(key="x", action="increment", id="child.increment"),
|
||||
]
|
||||
|
||||
def action_increment(self) -> None:
|
||||
nonlocal child_counter
|
||||
child_counter += 1
|
||||
|
||||
class MyApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Parent()
|
||||
yield Child()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.set_keymap({"parent.increment": "i"})
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
# Default binding is unbound due to keymap.
|
||||
await pilot.press("x")
|
||||
assert parent_counter == 0
|
||||
assert child_counter == 0
|
||||
|
||||
# New binding is active, parent is focused - action called.
|
||||
await pilot.press("i")
|
||||
assert parent_counter == 1
|
||||
assert child_counter == 0
|
||||
|
||||
# Tab to focus the child.
|
||||
await pilot.press("tab")
|
||||
|
||||
# Default binding is still active on the child.
|
||||
await pilot.press("x")
|
||||
assert parent_counter == 1
|
||||
assert child_counter == 1
|
||||
|
||||
# The binding from the keymap only affects the parent, so
|
||||
# pressing it with the child focused does nothing.
|
||||
await pilot.press("i")
|
||||
assert parent_counter == 1
|
||||
assert child_counter == 1
|
||||
Loading…
Reference in a new issue