diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6bbb814d0..7c1a044677 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/FAQ.md b/docs/FAQ.md
index 603600ab64..31d72b0d51 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -58,7 +58,7 @@ Some terminal emulators have a translucent background feature which allows the d
This feature is unlikely to work with Textual, as the translucency effect requires the use of ANSI background colors, which Textual doesn't use.
Textual uses 16.7 million colors where available which enables consistent colors across all platforms and additional effects which aren't possible with ANSI colors.
-For more information on ANSI colors in Textual, see [Why no Ansi Themes?](#why-doesnt-textual-support-ansi-themes).
+For more information on ANSI colors in Textual, see [Why no ANSI Themes?](#why-doesnt-textual-support-ansi-themes).
---
@@ -68,7 +68,7 @@ For more information on ANSI colors in Textual, see [Why no Ansi Themes?](#why-d
!!! tip
See [*How To Center Things*](https://textual.textualize.io/how-to/center-things/) in the
- Textual documentation for a more comprensive answer to this question.
+ Textual documentation for a more comprehensive answer to this question.
To center a widget within a container use
[`align`](https://textual.textualize.io/styles/align/). But remember that
@@ -130,7 +130,7 @@ If you want them more like this:
+---------------+
```
-the best approach is to wrap each widget in a [`Center`
+The best approach is to wrap each widget in a [`Center`
container](https://textual.textualize.io/api/containers/#textual.containers.Center)
that individually centers it. For example:
@@ -267,10 +267,10 @@ work in different environments you can try them out with `textual keys`.
---
-
+
## Why doesn't Textual look good on macOS?
-You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particuarily when it comes to box characters.
+You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particularly when it comes to box characters.
For instance, you may find it displays misaligned blocks and lines like this:
@@ -305,7 +305,7 @@ We recommend any of the following terminals:
---
-
+
## Why doesn't Textual support ANSI themes?
Textual will not generate escape sequences for the 16 themeable *ANSI* colors.
@@ -319,22 +319,22 @@ Textual has a design system which guarantees apps will be readable on all platfo
There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.
-!!! Changed in 0.80.0
+!!! tip "Changed in version 0.80.0"
- Textual added an `ansi_color` boolean to App. If you set this to `True`, then Textual will
- not attempt to convert ansi colors. Note that you will lose transparency effects if you enable
- this setting.
+ Textual added an `ansi_color` boolean to App. If you set this to `True`, then Textual will not attempt to convert ANSI colors. Note that you will lose transparency effects if you enable this setting.
---
-
+
## Why doesn't the `DataTable` scroll programmatically?
If scrolling in your `DataTable` is _apparently_ broken, it may be because your `DataTable` is using the default value of `height: auto`.
This means that the table will be sized to fit its rows without scrolling, which may cause the *container* (typically the screen) to scroll.
If you would like the table itself to scroll, set the height to something other than `auto`, like `100%`.
-**NOTE:** As of Textual v0.31.0 the `max-height` of a `DataTable` is set to `100%`, this will mean that the above is no longer the default experience.
+!!! note
+
+ As of Textual v0.31.0 the `max-height` of a `DataTable` is set to `100%`, this will mean that the above is no longer the default experience.
---
diff --git a/docs/api/index.md b/docs/api/index.md
index 989244f2c8..9d4916ce93 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -1,5 +1,5 @@
# API
-This is a API-level reference to the Textual API. Click the links to your left (or in the burger menu) to open a reference for each module.
+This is a API-level reference to the Textual API. Click the links to your left (or in the :octicons-three-bars-16: menu) to open a reference for each module.
If you are new to Textual, you may want to read the [tutorial](./../tutorial.md) or [guide](../guide/index.md) first.
diff --git a/docs/api/renderables.md b/docs/api/renderables.md
index 5add63e086..3b93b57b51 100644
--- a/docs/api/renderables.md
+++ b/docs/api/renderables.md
@@ -2,7 +2,7 @@
title: "textual.renderables"
---
-A collection of Rich renderables which may be returned from a widget's `render()` method.
+A collection of Rich renderables which may be returned from a widget's [`render()`][textual.widget.Widget.render] method.
::: textual.renderables.bar
::: textual.renderables.blank
diff --git a/docs/events/index.md b/docs/events/index.md
index 6fe8635eed..cadff3a29c 100644
--- a/docs/events/index.md
+++ b/docs/events/index.md
@@ -2,4 +2,4 @@
A reference to Textual [events](../guide/events.md).
-See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).
+See the links to the left of the page, or click :octicons-three-bars-16: (top left).
diff --git a/docs/help.md b/docs/help.md
index a27dae4e0b..33d8b5fbf2 100644
--- a/docs/help.md
+++ b/docs/help.md
@@ -4,7 +4,7 @@ If you need help with any aspect of Textual, let us know! We would be happy to h
## Bugs and feature requests
-Report bugs via GitHub on the Textual [issues](https://github.com/Textualize/textual/issues) page. You can also post feature requests via GitHub issues, but see the [roadmap](./roadmap.md) first.
+Report bugs via GitHub on the Textual [issues](https://github.com/Textualize/textual/issues) page. You can also post feature requests via GitHub issues, but see the [Roadmap](./roadmap.md) first.
## Help with using Textual
@@ -12,4 +12,4 @@ You can seek help with using Textual [in the discussion area on GitHub](https://
## Discord Server
-For more realtime feedback or chat, join our discord server to connect with the [Textual community](https://discord.gg/Enf6Z3qhVr).
+For more realtime feedback or chat, join our Discord server to connect with the [Textual community](https://discord.gg/Enf6Z3qhVr).
diff --git a/docs/index.md b/docs/index.md
index 17875dc457..4d34102f03 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -138,7 +138,7 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t
=== "stopwatch.tcss"
```css
- --8<-- "examples/calculator.tcss"
+ --8<-- "docs/examples/tutorial/stopwatch.tcss"
```
diff --git a/questions/align-center-middle.question.md b/questions/align-center-middle.question.md
index a33ff239be..a71d0fa488 100644
--- a/questions/align-center-middle.question.md
+++ b/questions/align-center-middle.question.md
@@ -12,7 +12,7 @@ alt_titles:
!!! tip
See [*How To Center Things*](https://textual.textualize.io/how-to/center-things/) in the
- Textual documentation for a more comprensive answer to this question.
+ Textual documentation for a more comprehensive answer to this question.
To center a widget within a container use
[`align`](https://textual.textualize.io/styles/align/). But remember that
@@ -74,7 +74,7 @@ If you want them more like this:
+---------------+
```
-the best approach is to wrap each widget in a [`Center`
+The best approach is to wrap each widget in a [`Center`
container](https://textual.textualize.io/api/containers/#textual.containers.Center)
that individually centers it. For example:
diff --git a/questions/datatable-doesnt-scroll.question.md b/questions/datatable-doesnt-scroll.question.md
index e386aad1ad..c47be64758 100644
--- a/questions/datatable-doesnt-scroll.question.md
+++ b/questions/datatable-doesnt-scroll.question.md
@@ -9,4 +9,6 @@ If scrolling in your `DataTable` is _apparently_ broken, it may be because your
This means that the table will be sized to fit its rows without scrolling, which may cause the *container* (typically the screen) to scroll.
If you would like the table itself to scroll, set the height to something other than `auto`, like `100%`.
-**NOTE:** As of Textual v0.31.0 the `max-height` of a `DataTable` is set to `100%`, this will mean that the above is no longer the default experience.
+!!! note
+
+ As of Textual v0.31.0 the `max-height` of a `DataTable` is set to `100%`, this will mean that the above is no longer the default experience.
diff --git a/questions/transparent-background.question.md b/questions/transparent-background.question.md
index 83dd15297d..dd4a0af5f5 100644
--- a/questions/transparent-background.question.md
+++ b/questions/transparent-background.question.md
@@ -11,4 +11,4 @@ Some terminal emulators have a translucent background feature which allows the d
This feature is unlikely to work with Textual, as the translucency effect requires the use of ANSI background colors, which Textual doesn't use.
Textual uses 16.7 million colors where available which enables consistent colors across all platforms and additional effects which aren't possible with ANSI colors.
-For more information on ANSI colors in Textual, see [Why no Ansi Themes?](#why-doesnt-textual-support-ansi-themes).
+For more information on ANSI colors in Textual, see [Why no ANSI Themes?](#why-doesnt-textual-support-ansi-themes).
diff --git a/questions/why-looks-bad-on-macos.question.md b/questions/why-looks-bad-on-macos.question.md
index 8f40aa6ef6..e885fccde7 100644
--- a/questions/why-looks-bad-on-macos.question.md
+++ b/questions/why-looks-bad-on-macos.question.md
@@ -10,7 +10,7 @@ alt_titles:
- "macOS terminal"
---
-You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particuarily when it comes to box characters.
+You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particularly when it comes to box characters.
For instance, you may find it displays misaligned blocks and lines like this:
diff --git a/questions/why-no-ansi-themes.question.md b/questions/why-no-ansi-themes.question.md
index a0c759ad81..0f080d6dce 100644
--- a/questions/why-no-ansi-themes.question.md
+++ b/questions/why-no-ansi-themes.question.md
@@ -16,8 +16,6 @@ Textual has a design system which guarantees apps will be readable on all platfo
There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.
-!!! Changed in 0.80.0
+!!! tip "Changed in version 0.80.0"
- Textual added an `ansi_color` boolean to App. If you set this to `True`, then Textual will
- not attempt to convert ansi colors. Note that you will lose transparency effects if you enable
- this setting.
+ Textual added an `ansi_color` boolean to App. If you set this to `True`, then Textual will not attempt to convert ANSI colors. Note that you will lose transparency effects if you enable this setting.
diff --git a/src/textual/app.py b/src/textual/app.py
index 9a6267bc3f..73a22ad7f4 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -91,7 +91,7 @@
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 @@ def __init__(
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 @@ def __init__(
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 @@ async def _check_bindings(self, key: str, priority: bool = False) -> bool:
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
@@ -3829,7 +3876,7 @@ async def action_pop_screen(self) -> None:
self.pop_screen()
async def action_switch_mode(self, mode: str) -> None:
- """An [action](/guide/actions) that switches to the given mode.."""
+ """An [action](/guide/actions) that switches to the given mode."""
self.switch_mode(mode)
async def action_back(self) -> None:
diff --git a/src/textual/binding.py b/src/textual/binding.py
index 9501d4b567..09e0b3b892 100644
--- a/src/textual/binding.py
+++ b/src/textual/binding.py
@@ -7,8 +7,9 @@
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 @@
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 @@ def parse_key(self) -> tuple[list[str], str]:
*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 @@ def __init__(
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 @@ def merge(cls, bindings: Iterable[BindingsMap]) -> 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 @@ def get_bindings_for_key(self, key: str) -> list[Binding]:
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."""
diff --git a/src/textual/dom.py b/src/textual/dom.py
index c955eb9fb3..399a9099fb 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -218,7 +218,7 @@ def __init__(
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 @@ def _merge_bindings(cls) -> BindingsMap:
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:
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 02c4b80590..02eeb9b4bd 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -348,7 +348,7 @@ def set_timer(
name: str | None = None,
pause: bool = False,
) -> Timer:
- """call a function after a delay.
+ """Call a function after a delay.
Example:
```python
diff --git a/src/textual/screen.py b/src/textual/screen.py
index 997d5c1c90..a13e18370b 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -332,8 +332,8 @@ def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
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 @@ def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
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 @@ def active_bindings(self) -> dict[str, ActiveBinding]:
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
diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py
index 3788310f65..56b947172f 100644
--- a/src/textual/scroll_view.py
+++ b/src/textual/scroll_view.py
@@ -1,5 +1,5 @@
"""
-`ScrollView` is a base class for [line api](/guide/widgets#line-api) widgets.
+`ScrollView` is a base class for [Line API](/guide/widgets#line-api) widgets.
"""
from __future__ import annotations
diff --git a/src/textual/strip.py b/src/textual/strip.py
index 9d26d4fac9..71bfd78089 100644
--- a/src/textual/strip.py
+++ b/src/textual/strip.py
@@ -2,7 +2,7 @@
This module contains the `Strip` class and related objects.
A `Strip` contains the result of rendering a widget.
-See [line API](/guide/widgets#line-api) for how to use Strips.
+See [Line API](/guide/widgets#line-api) for how to use Strips.
"""
from __future__ import annotations
diff --git a/src/textual/walk.py b/src/textual/walk.py
index 0f6790c882..dcda856e49 100644
--- a/src/textual/walk.py
+++ b/src/textual/walk.py
@@ -50,29 +50,37 @@ def walk_depth_first(
Args:
root: The root note (starting point).
- filter_type: Optional DOMNode subclass to filter by, or ``None`` for no filter.
+ filter_type: Optional DOMNode subclass to filter by, or `None` for no filter.
with_root: Include the root in the walk.
Returns:
- An iterable of DOMNodes, or the type specified in ``filter_type``.
+ An iterable of DOMNodes, or the type specified in `filter_type`.
"""
- from textual.dom import DOMNode
-
stack: list[Iterator[DOMNode]] = [iter(root.children)]
pop = stack.pop
push = stack.append
- check_type = filter_type or DOMNode
- if with_root and isinstance(root, check_type):
- yield root
- while stack:
- if (node := next(stack[-1], None)) is None:
- pop()
- else:
- if isinstance(node, check_type):
+ if filter_type is None:
+ if with_root:
+ yield root
+ while stack:
+ if (node := next(stack[-1], None)) is None:
+ pop()
+ else:
yield node
- if children := node._nodes:
- push(iter(children))
+ if children := node._nodes:
+ push(iter(children))
+ else:
+ if with_root and isinstance(root, filter_type):
+ yield root
+ while stack:
+ if (node := next(stack[-1], None)) is None:
+ pop()
+ else:
+ if isinstance(node, filter_type):
+ yield node
+ if children := node._nodes:
+ push(iter(children))
if TYPE_CHECKING:
@@ -108,11 +116,11 @@ def walk_breadth_first(
Args:
root: The root note (starting point).
- filter_type: Optional DOMNode subclass to filter by, or ``None`` for no filter.
+ filter_type: Optional DOMNode subclass to filter by, or `None` for no filter.
with_root: Include the root in the walk.
Returns:
- An iterable of DOMNodes, or the type specified in ``filter_type``.
+ An iterable of DOMNodes, or the type specified in `filter_type`.
"""
from textual.dom import DOMNode
diff --git a/src/textual/widget.py b/src/textual/widget.py
index b7bcfa7df3..80a5093d3d 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -3753,7 +3753,7 @@ def set_focus(widget: Widget) -> None:
def blur(self) -> Self:
"""Blur (un-focus) the widget.
- Focus will be moved to the next available widget in the focus chain..
+ Focus will be moved to the next available widget in the focus chain.
Returns:
The `Widget` instance.
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_display_footer_and_help_panel.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_display_footer_and_help_panel.svg
new file mode 100644
index 0000000000..9a79c1ca5d
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_display_footer_and_help_panel.svg
@@ -0,0 +1,158 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_key_display.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_key_display.svg
new file mode 100644
index 0000000000..cc232f86be
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_key_display.svg
@@ -0,0 +1,158 @@
+
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 427e55a82a..f919b0e35e 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -2,12 +2,13 @@
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 on_mount(self) -> None:
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"""
diff --git a/tests/test_keymap.py b/tests/test_keymap.py
new file mode 100644
index 0000000000..fd92ff2990
--- /dev/null
+++ b/tests/test_keymap.py
@@ -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