From 523fe094e83f178c354de2fd8eb7d6daa1988893 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:24:51 +0800 Subject: [PATCH 01/17] factory build --- docs/FAQ.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) 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: Screenshot 2023-06-19 at 10 43 02 @@ -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. --- From c3d0028a271ef1249a45da23dd22c8694d0abd7d Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:27:28 +0800 Subject: [PATCH 02/17] Fix stopwatch tcss file path --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" ``` From a631c24393460b40a6ba1998f807e2aa395ddc74 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:28:34 +0800 Subject: [PATCH 03/17] Fix capitalization --- docs/help.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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). From e0d145e09ab09c2d8da3749b8dbf32db7da52f58 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:42:36 +0800 Subject: [PATCH 04/17] Use the hamburger menu icon. --- docs/api/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From d20b9d5bb546ded844dbe01cbf40f65fb27c1f64 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:43:41 +0800 Subject: [PATCH 05/17] Add link to widget's render() for convenience. --- docs/api/renderables.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 79810e5447a9221c85429b4fc43d60767b78ccfd Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:44:42 +0800 Subject: [PATCH 06/17] Use hamburger menu icon. --- docs/events/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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). From f51a2465078547f5251b170a862e09d24746c629 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:49:28 +0800 Subject: [PATCH 07/17] Fix typo and capitalisation --- questions/align-center-middle.question.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: From 4282f61f3155ea82dec757ae33f6922ece4b8ddf Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:50:42 +0800 Subject: [PATCH 08/17] Use mkdoc's admonition syntax for a cleaner note. --- questions/datatable-doesnt-scroll.question.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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. From 8a76d0f99c77a3bad9cd14cd5aa9f81eca2327f7 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:51:12 +0800 Subject: [PATCH 09/17] Fix capitalisation --- questions/transparent-background.question.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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). From bc36cf59578932ccbfd1b093338f82d518653e08 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:51:37 +0800 Subject: [PATCH 10/17] Fix typo --- questions/why-looks-bad-on-macos.question.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: Screenshot 2023-06-19 at 10 43 02 From 41e954b7f4af1b0087dafdc07283bd48a8768a4e Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:52:42 +0800 Subject: [PATCH 11/17] Fix admonition syntax to use tip --- questions/why-no-ansi-themes.question.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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. From 8757ab56858f89bc9d843a1969860ccc352df461 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:54:05 +0800 Subject: [PATCH 12/17] Remove extra period in docstring --- src/textual/app.py | 2 +- src/textual/widget.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 9a6267bc3f..fec088618c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3829,7 +3829,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/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. From 2e9cb16b06f4cc66f0d1b24139585d4eb7750fe9 Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:54:46 +0800 Subject: [PATCH 13/17] Capitalise --- src/textual/message_pump.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 433dc333c7c1c799f88db24a9ceb40ac60f95a0f Mon Sep 17 00:00:00 2001 From: Pure <111236541+PureStupid@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:55:36 +0800 Subject: [PATCH 14/17] Capitalizing --- src/textual/scroll_view.py | 2 +- src/textual/strip.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From ea1a5a31b239ffc1c877239f9296fdc5f7151864 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Sep 2024 15:29:44 +0100 Subject: [PATCH 15/17] faster walk --- src/textual/walk.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/textual/walk.py b/src/textual/walk.py index 0f6790c882..3f158abc24 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -56,23 +56,31 @@ def walk_depth_first( Returns: 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: From 8615d6d876931083302141fe76a975d53d44dffb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 29 Sep 2024 19:19:25 +0100 Subject: [PATCH 16/17] docstring syntax --- src/textual/walk.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/walk.py b/src/textual/walk.py index 3f158abc24..dcda856e49 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -50,11 +50,11 @@ 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`. """ stack: list[Iterator[DOMNode]] = [iter(root.children)] pop = stack.pop @@ -116,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 From d472cb582ed4c7ecdd9acb11884877c171b1b7b4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Sep 2024 09:17:59 +0100 Subject: [PATCH 17/17] Keymaps (#5038) --- CHANGELOG.md | 6 + src/textual/app.py | 51 ++++- src/textual/binding.py | 201 ++++++++++++++---- src/textual/dom.py | 5 +- src/textual/screen.py | 16 +- ...bindings_display_footer_and_help_panel.svg | 158 ++++++++++++++ .../test_keymap_bindings_key_display.svg | 158 ++++++++++++++ tests/snapshot_tests/test_snapshots.py | 83 +++++++- tests/test_keymap.py | 194 +++++++++++++++++ 9 files changed, 828 insertions(+), 44 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_display_footer_and_help_panel.svg create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_keymap_bindings_key_display.svg create mode 100644 tests/test_keymap.py 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/src/textual/app.py b/src/textual/app.py index fec088618c..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 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/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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Counter + + + + + + + + + + Counter                                            +      tabFocus Next      +shift+tabFocus Previous  + +       ^cQuit            +       ^ppalette Open  +command palette +      k +Increment       +    ↓ - jDecrement       + + + + + + + + + + + + + + + k Increment  ↓ Decrement ^p palette + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + Check the footer and help panel                    +         tabFocus Next   +   shift+tabFocus        +Previous     + +          ^cQuit         +          ^ppalette Open +command  +palette +     correctIncrement    +     correct +     correct +     correct + + + + + + + + + + + correct Increment ^p palette + + + 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