Merge pull request #1417 from Textualize/key-refactor

keys refactor
This commit is contained in:
Will McGugan
2022-12-21 15:54:34 +00:00
committed by GitHub
21 changed files with 261 additions and 148 deletions

View File

@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.8.0] - Unreleased ## [0.8.0] - Unreleased
### Fixed ### Fixed
@@ -18,10 +19,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `textual.actions.SkipAction` exception which can be raised from an action to allow parents to process bindings. - Added `textual.actions.SkipAction` exception which can be raised from an action to allow parents to process bindings.
- Added `textual keys` preview. - Added `textual keys` preview.
- Added ability to bind to a character in addition to key name. i.e. you can bind to "." or "full_stop".
- Added TextLog.shrink attribute to allow renderable to reduce in size to fit width.
### Changed ### Changed
- Moved Ctrl+C, tab, and shift+tab to App BINDINGS - Deprecated `PRIORITY_BINDINGS` class variable.
- Renamed `char` to `character` on Key event.
- Renamed `key_name` to `name` on Key event.
- Queries/`walk_children` no longer includes self in results by default https://github.com/Textualize/textual/pull/1416 - Queries/`walk_children` no longer includes self in results by default https://github.com/Textualize/textual/pull/1416
## [0.7.0] - 2022-12-17 ## [0.7.0] - 2022-12-17

View File

@@ -90,6 +90,8 @@ Textual will run actions bound to keys. The following example adds key [bindings
If you run this example, you can change the background by pressing keys in addition to clicking links. If you run this example, you can change the background by pressing keys in addition to clicking links.
See the previous section on [input](./input.md#bindings) for more information on bindings.
## Namespaces ## Namespaces
Textual will look for action methods in the class where they are defined (App, Screen, or Widget). If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions. Textual will look for action methods in the class where they are defined (App, Screen, or Widget). If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions.
@@ -124,37 +126,9 @@ In the previous example if you wanted a link to set the background on the app ra
Textual supports the following builtin actions which are defined on the app. Textual supports the following builtin actions which are defined on the app.
- [action_bell][textual.app.App.action_bell]
### Bell - [action_push_screen][textual.app.App.action_push_screen]
- [action_pop_screen][textual.app.App.action_pop_screen]
::: textual.app.App.action_bell - [action_switch_screen][textual.app.App.action_switch_screen]
options: - [action_screenshot][textual.app.App.action_screenshot]
show_root_heading: false - [action_toggle_dark][textual.app.App.action_toggle_dark]
### Push screen
::: textual.app.App.action_push_screen
### Pop screen
::: textual.app.App.action_pop_screen
### Screenshot
::: textual.app.App.action_screenshot
### Switch screen
::: textual.app.App.action_switch_screen
### Toggle_dark
::: textual.app.App.action_toggle_dark
### Quit
::: textual.app.App.action_quit

View File

@@ -10,7 +10,7 @@ This chapter will discuss how to make your app respond to input in the form of k
## Keyboard input ## Keyboard input
The most fundamental way to receive input is via [Key](./events/key) events. Let's write an app to show key events as you type. The most fundamental way to receive input is via [Key][textual.events.Key] events which are sent to your app when the user presses a key. Let's write an app to show key events as you type.
=== "key01.py" === "key01.py"
@@ -23,25 +23,52 @@ The most fundamental way to receive input is via [Key](./events/key) events. Let
```{.textual path="docs/examples/guide/input/key01.py", press="T,e,x,t,u,a,l,!,_"} ```{.textual path="docs/examples/guide/input/key01.py", press="T,e,x,t,u,a,l,!,_"}
``` ```
Note the key event handler on the app which logs all key events. If you press any key it will show up on the screen. When you press a key, the app will receive the event and write it to a [TextLog](../widgets/text_log.md) widget. Try pressing a few keys to see what happens.
### Attributes !!! tip
There are two main attributes on a key event. The `key` attribute is the _name_ of the key which may be a single character, or a longer identifier. Textual ensures that the `key` attribute could always be used in a method name. For a more feature feature rich version of this example, run `textual keys` from the command line.
Key events also contain a `char` attribute which contains a single character if it is printable, or ``None`` if it is not printable (like a function key which has no corresponding character). ### Key Event
To illustrate the difference between `key` and `char`, try `key01.py` with the space key. You should see something like the following: The key event contains the following attributes which your app can use to know how to respond.
```{.textual path="docs/examples/guide/input/key01.py", press="space,_"} #### key
``` The `key` attribute is a string which identifies the key that was pressed. The value of `key` will be a single character for letters and numbers, or a longer identifier for other keys.
Some keys may be combined with the ++shift++ key. In the case of letters, this will result in a capital letter as you might expect. For non-printable keys, the `key` attribute will be prefixed with `shift+`. For example, ++shift+home++ will produce an event with `key="shift+home"`.
Many keys can also be combined with ++ctrl++ which will prefix the key with `ctrl+`. For instance, ++ctrl+p++ will produce an event with `key="ctrl+p"`.
!!! warning
Not all keys combinations are supported in terminals and some keys may be intercepted by your OS. If in doubt, run `textual keys` from the command line.
#### character
If the key has an associated printable character, then `character` will contain a string with a single Unicode character. If there is no printable character for the key (such as for function keys) then `character` will be `None`.
For example the ++p++ key will produce `character="p"` but ++f2++ will produce `character=None`.
#### name
The `name` attribute is similar to `key` but, unlike `key`, is guaranteed to be valid within a Python function name. Textual derives `name` from the `key` attribute by lower casing it and replacing `+` with `_`. Upper case letters are prefixed with `upper_` to distinguish them from lower case names.
For example, ++ctrl+p++ produces `name="ctrl_p"` and ++shift+p++ produces `name="upper_p"`.
#### is_printable
The `is_printable` attribute is a boolean which indicates if the key would typically result in something that could be used in an input widget. If `is_printable` is `False` then the key is a control code or function key that you wouldn't expect to produce anything in an input.
#### aliases
Some keys or combinations of keys can produce the same event. For instance, the ++tab++ key is indistinguishable from ++ctrl+i++ in the terminal. For such keys, Textual events will contain a list of the possible keys that may have produced this event. In the case of ++tab++, the `aliases` attribute will contain `["tab", "ctrl+i"]`
Note that the `key` attribute contains the word "space" while the `char` attribute contains a literal space.
### Key methods ### Key methods
Textual offers a convenient way of handling specific keys. If you create a method beginning with `key_` followed by the name of a key, then that method will be called in response to the key. Textual offers a convenient way of handling specific keys. If you create a method beginning with `key_` followed by the key name (the event's `name` attribute), then that method will be called in response to the key press.
Let's add a key method to the example code. Let's add a key method to the example code.
@@ -127,24 +154,28 @@ Note how the footer displays bindings and makes them clickable.
Multiple keys can be bound to a single action by comma-separating them. Multiple keys can be bound to a single action by comma-separating them.
For example, `("r,t", "add_bar('red')", "Add Red")` means both ++r++ and ++t++ are bound to `add_bar('red')`. For example, `("r,t", "add_bar('red')", "Add Red")` means both ++r++ and ++t++ are bound to `add_bar('red')`.
!!! note
Ordinarily a binding on a focused widget has precedence over the same key binding at a higher level. However, bindings at the `App` or `Screen` level always have priority.
The priority of a single binding can be controlled with the `priority` parameter of a `Binding` instance. Set it to `True` to give it priority, or `False` to not.
The default priority of all bindings on a class can be controlled with the `PRIORITY_BINDINGS` class variable. Set it to `True` or `False` to set the default priroty for all `BINDINGS`.
### Binding class ### Binding class
The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a [Binding][textual.binding.Binding] instance which exposes a few more options. The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a [Binding][textual.binding.Binding] instance which exposes a few more options.
### Why use bindings? ### Priority bindings
Bindings are particularly useful for configurable hot-keys. Bindings can also be inspected in widgets such as [Footer](../widgets/footer.md). Individual bindings may be marked as a *priority*, which means they will be checked prior to the bindings of the focused widget. This feature is often used to create hot-keys on the app or screen. Such bindings can not be disabled by binding the same key on a widget.
You can create priority key bindings by setting `priority=True` on the Binding object. Textual uses this feature to add a default binding for ++ctrl+c++ so there is always a way to exit the app. Here's the bindings from the App base class. Note the first binding is set as a priority:
```python
BINDINGS = [
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
Binding("tab", "focus_next", "Focus Next", show=False),
Binding("shift+tab", "focus_previous", "Focus Previous", show=False),
]
```
### Show bindings
The [footer](../widgets/footer.md) widget can inspect bindings to display available keys. If you don't want a binding to display in the footer you can set `show=False`. The default bindings on App do this so that the standard ++ctrl+c++, ++tab++ and ++shift+tab++ bindings don't typically appear in the footer.
In a future version of Textual it will also be possible to specify bindings in a configuration file, which will allow users to override app bindings.
## Mouse Input ## Mouse Input

View File

@@ -232,8 +232,6 @@ class App(Generic[ReturnType], DOMNode):
} }
""" """
PRIORITY_BINDINGS = True
SCREENS: dict[str, Screen | Callable[[], Screen]] = {} SCREENS: dict[str, Screen | Callable[[], Screen]] = {}
_BASE_PATH: str | None = None _BASE_PATH: str | None = None
CSS_PATH: CSSPathType = None CSS_PATH: CSSPathType = None
@@ -242,10 +240,8 @@ class App(Generic[ReturnType], DOMNode):
BINDINGS = [ BINDINGS = [
Binding("ctrl+c", "quit", "Quit", show=False, priority=True), Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
Binding("tab", "focus_next", "Focus Next", show=False, priority=False), Binding("tab", "focus_next", "Focus Next", show=False),
Binding( Binding("shift+tab", "focus_previous", "Focus Previous", show=False),
"shift+tab", "focus_previous", "Focus Previous", show=False, priority=False
),
] ]
title: Reactive[str] = Reactive("") title: Reactive[str] = Reactive("")

View File

@@ -5,6 +5,7 @@ from typing import Iterable, MutableMapping
import rich.repr import rich.repr
from textual.keys import _character_to_key
from textual._typing import TypeAlias from textual._typing import TypeAlias
BindingType: TypeAlias = "Binding | tuple[str, str, str]" BindingType: TypeAlias = "Binding | tuple[str, str, str]"
@@ -18,6 +19,10 @@ class NoBinding(Exception):
"""A binding was not found.""" """A binding was not found."""
class InvalidBinding(Exception):
"""Binding key is in an invalid format."""
@dataclass(frozen=True) @dataclass(frozen=True)
class Binding: class Binding:
"""The configuration of a key binding.""" """The configuration of a key binding."""
@@ -32,8 +37,8 @@ class Binding:
"""bool: Show the action in Footer, or False to hide.""" """bool: Show the action in Footer, or False to hide."""
key_display: str | None = None key_display: str | None = None
"""str | None: How the key should be shown in footer.""" """str | None: How the key should be shown in footer."""
priority: bool | None = None priority: bool = False
"""bool | None: Is this a priority binding, checked form app down to focused widget?""" """bool: Enable priority binding for this key."""
@rich.repr.auto @rich.repr.auto
@@ -43,13 +48,11 @@ class Bindings:
def __init__( def __init__(
self, self,
bindings: Iterable[BindingType] | None = None, bindings: Iterable[BindingType] | None = None,
default_priority: bool | None = None,
) -> None: ) -> None:
"""Initialise a collection of bindings. """Initialise a collection of bindings.
Args: Args:
bindings (Iterable[BindingType] | None, optional): An optional set of initial bindings. bindings (Iterable[BindingType] | None, optional): An optional set of initial bindings.
default_priority (bool | None, optional): The default priority of the bindings.
Note: Note:
The iterable of bindings can contain either a `Binding` The iterable of bindings can contain either a `Binding`
@@ -71,17 +74,20 @@ class Bindings:
# be a list of keys, so now we unroll that single Binding # be a list of keys, so now we unroll that single Binding
# into a (potential) collection of Binding instances. # into a (potential) collection of Binding instances.
for key in binding.key.split(","): 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( yield Binding(
key=key.strip(), key=key,
action=binding.action, action=binding.action,
description=binding.description, description=binding.description,
show=binding.show, show=binding.show,
key_display=binding.key_display, key_display=binding.key_display,
priority=( priority=binding.priority,
default_priority
if binding.priority is None
else binding.priority
),
) )
self.keys: MutableMapping[str, Binding] = ( self.keys: MutableMapping[str, Binding] = (

View File

@@ -127,7 +127,7 @@ def colors():
@run.command("keys") @run.command("keys")
def keys(): def keys():
"""Show key events""" """Show key events."""
from textual.cli.previews import keys from textual.cli.previews import keys
keys.app.run() keys.app.run()

View File

@@ -9,7 +9,7 @@ from textual.widgets import Button, Header, TextLog
INSTRUCTIONS = """\ INSTRUCTIONS = """\
Press some keys! Press some keys!
Because we want to display all the keys, Ctrl+C won't work for this example. Use the button below to quit.\ Because we want to display all the keys, ctrl+C won't quit this example. Use the Quit button below to exit the app.\
""" """

View File

@@ -92,9 +92,6 @@ class DOMNode(MessagePump):
# Virtual DOM nodes # Virtual DOM nodes
COMPONENT_CLASSES: ClassVar[set[str]] = set() COMPONENT_CLASSES: ClassVar[set[str]] = set()
# Should the content of BINDINGS be treated as priority bindings?
PRIORITY_BINDINGS: ClassVar[bool] = False
# Mapping of key bindings # Mapping of key bindings
BINDINGS: ClassVar[list[BindingType]] = [] BINDINGS: ClassVar[list[BindingType]] = []
@@ -233,13 +230,13 @@ class DOMNode(MessagePump):
for base in reversed(cls.__mro__): for base in reversed(cls.__mro__):
if issubclass(base, DOMNode): if issubclass(base, DOMNode):
# See if the current class wants to set the bindings as
# priority bindings. If it doesn't have that property on the
# class, go with what we saw last.
priority = base.__dict__.get("PRIORITY_BINDINGS", priority)
if not base._inherit_bindings: if not base._inherit_bindings:
bindings.clear() bindings.clear()
bindings.append(Bindings(base.__dict__.get("BINDINGS", []), priority)) bindings.append(
Bindings(
base.__dict__.get("BINDINGS", []),
)
)
keys = {} keys = {}
for bindings_ in bindings: for bindings_ in bindings:
keys.update(bindings_.keys) keys.update(bindings_.keys)

View File

@@ -16,7 +16,6 @@ if TYPE_CHECKING:
from .timer import Timer as TimerClass from .timer import Timer as TimerClass
from .timer import TimerCallback from .timer import TimerCallback
from .widget import Widget from .widget import Widget
import asyncio
@rich.repr.auto @rich.repr.auto
@@ -183,7 +182,7 @@ class MouseRelease(Event, bubble=False):
class InputEvent(Event): class InputEvent(Event):
pass """Base class for input events."""
@rich.repr.auto @rich.repr.auto
@@ -191,47 +190,56 @@ class Key(InputEvent):
"""Sent when the user hits a key on the keyboard. """Sent when the user hits a key on the keyboard.
Args: Args:
sender (MessageTarget): The sender of the event (the App). sender (MessageTarget): The sender of the event (always the App).
key (str): A key name (textual.keys.Keys). key (str): The key that was pressed.
char (str | None, optional): A printable character or None if it is not printable. character (str | None, optional): A printable character or ``None`` if it is not printable.
Attributes: Attributes:
key_aliases (list[str]): The aliases for the key, including the key itself aliases (list[str]): The aliases for the key, including the key itself.
""" """
__slots__ = ["key", "char", "key_aliases"] __slots__ = ["key", "character", "aliases"]
def __init__(self, sender: MessageTarget, key: str, char: str | None) -> None: def __init__(self, sender: MessageTarget, key: str, character: str | None) -> None:
super().__init__(sender) super().__init__(sender)
self.key = key self.key = key
self.char = (key if len(key) == 1 else None) if char is None else char self.character = (
self.key_aliases = [_normalize_key(alias) for alias in _get_key_aliases(key)] (key if len(key) == 1 else None) if character is None else character
)
self.aliases = _get_key_aliases(key)
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield "key", self.key yield "key", self.key
yield "char", self.char yield "character", self.character
yield "name", self.name
yield "is_printable", self.is_printable yield "is_printable", self.is_printable
yield "key_aliases", self.key_aliases, [self.key_name] yield "aliases", self.aliases, [self.key]
@property @property
def key_name(self) -> str | None: def name(self) -> str:
"""Name of a key suitable for use as a Python identifier.""" """Name of a key suitable for use as a Python identifier."""
return _normalize_key(self.key) return _key_to_identifier(self.key).lower()
@property
def name_aliases(self) -> list[str]:
"""The corresponding name for every alias in `aliases` list."""
return [_key_to_identifier(key) for key in self.aliases]
@property @property
def is_printable(self) -> bool: def is_printable(self) -> bool:
"""Return True if the key is printable. Currently, we assume any key event that """Check if the key is printable (produces a unicode character).
isn't defined in key bindings is printable.
Returns: Returns:
bool: True if the key is printable. bool: True if the key is printable.
""" """
return False if self.char is None else self.char.isprintable() return False if self.character is None else self.character.isprintable()
def _normalize_key(key: str) -> str: def _key_to_identifier(key: str) -> str:
"""Convert the key string to a name suitable for use as a Python identifier.""" """Convert the key string to a name suitable for use as a Python identifier."""
return key.replace("+", "_") if len(key) == 1 and key.isupper():
key = f"upper_{key.lower()}"
return key.replace("+", "_").lower()
@rich.repr.auto @rich.repr.auto

View File

@@ -70,10 +70,10 @@ class Keys(str, Enum):
ControlShift9 = "ctrl+shift+9" ControlShift9 = "ctrl+shift+9"
ControlShift0 = "ctrl+shift+0" ControlShift0 = "ctrl+shift+0"
ControlBackslash = "ctrl+\\" ControlBackslash = "ctrl+backslash"
ControlSquareClose = "ctrl+]" ControlSquareClose = "ctrl+right_square_bracked"
ControlCircumflex = "ctrl+^" ControlCircumflex = "ctrl+circumflex_accent"
ControlUnderscore = "ctrl+_" ControlUnderscore = "ctrl+underscore"
Left = "left" Left = "left"
Right = "right" Right = "right"
@@ -256,3 +256,14 @@ def _get_key_display(key: str) -> str:
if unicode_character.isprintable(): if unicode_character.isprintable():
return unicode_character return unicode_character
return upper_original return upper_original
def _character_to_key(character: str) -> str:
"""Convert a single character to a key value."""
assert len(character) == 1
if not character.isalnum():
key = unicodedata.name(character).lower().replace("-", "_").replace(" ", "_")
else:
key = character
key = KEY_NAME_REPLACEMENTS.get(key, key)
return key

View File

@@ -24,7 +24,6 @@ class Message:
__slots__ = [ __slots__ = [
"sender", "sender",
"name",
"time", "time",
"_forwarded", "_forwarded",
"_no_default_action", "_no_default_action",
@@ -40,13 +39,14 @@ class Message:
def __init__(self, sender: MessageTarget) -> None: def __init__(self, sender: MessageTarget) -> None:
self.sender = sender self.sender = sender
self.name = camel_to_snake(self.__class__.__name__)
self.time = _clock.get_time_no_wait() self.time = _clock.get_time_no_wait()
self._forwarded = False self._forwarded = False
self._no_default_action = False self._no_default_action = False
self._stop_propagation = False self._stop_propagation = False
name = camel_to_snake(self.__class__.__name__)
self._handler_name = ( self._handler_name = (
f"on_{self.namespace}_{self.name}" if self.namespace else f"on_{self.name}" f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
) )
super().__init__() super().__init__()

View File

@@ -593,12 +593,12 @@ class MessagePump(metaclass=MessagePumpMeta):
handled = False handled = False
invoked_method = None invoked_method = None
key_name = event.key_name key_name = event.name
if not key_name: if not key_name:
return False return False
for key_alias in event.key_aliases: for key_method_name in event.name_aliases:
key_method = get_key_handler(self, key_alias) key_method = get_key_handler(self, key_method_name)
if key_method is not None: if key_method is not None:
if invoked_method: if invoked_method:
_raise_duplicate_key_handlers_error( _raise_duplicate_key_handlers_error(

View File

@@ -29,7 +29,6 @@ class Screen(Widget):
# The screen is a special case and unless a class that inherits from us # The screen is a special case and unless a class that inherits from us
# says otherwise, all screen-level bindings should be treated as having # says otherwise, all screen-level bindings should be treated as having
# priority. # priority.
PRIORITY_BINDINGS = True
DEFAULT_CSS = """ DEFAULT_CSS = """
Screen { Screen {

View File

@@ -233,8 +233,8 @@ class Input(Widget, can_focus=True):
return return
elif event.is_printable: elif event.is_printable:
event.stop() event.stop()
assert event.char is not None assert event.character is not None
self.insert_text_at_cursor(event.char) self.insert_text_at_cursor(event.character)
event.prevent_default() event.prevent_default()
def on_paste(self, event: events.Paste) -> None: def on_paste(self, event: events.Paste) -> None:

View File

@@ -66,13 +66,15 @@ class TextLog(ScrollView, can_focus=True):
content: RenderableType | object, content: RenderableType | object,
width: int | None = None, width: int | None = None,
expand: bool = False, expand: bool = False,
shrink: bool = True,
) -> None: ) -> None:
"""Write text or a rich renderable. """Write text or a rich renderable.
Args: Args:
content (RenderableType): Rich renderable (or text). content (RenderableType): Rich renderable (or text).
width (int): Width to render or None to use optimal width. Defaults to None. width (int): Width to render or None to use optimal width. Defaults to `None`.
expand (bool): Enable expand to widget width, or False to use `width`. expand (bool): Enable expand to widget width, or False to use `width`. Defaults to `False`.
shrink (bool): Enable shrinking of content to fit width. Defaults to `True`.
""" """
renderable: RenderableType renderable: RenderableType
@@ -95,14 +97,17 @@ class TextLog(ScrollView, can_focus=True):
if isinstance(renderable, Text) and not self.wrap: if isinstance(renderable, Text) and not self.wrap:
render_options = render_options.update(overflow="ignore", no_wrap=True) render_options = render_options.update(overflow="ignore", no_wrap=True)
if expand: render_width = measure_renderables(
render_width = self.scrollable_content_region.width console, render_options, [renderable]
else: ).maximum
render_width = ( container_width = (
measure_renderables(console, render_options, [renderable]).maximum self.scrollable_content_region.width if width is None else width
if width is None )
else width
) if expand and render_width < container_width:
render_width = container_width
if shrink and render_width > container_width:
render_width = container_width
segments = self.app.console.render( segments = self.app.console.render(
renderable, render_options.update_width(render_width) renderable, render_options.update_width(render_width)

View File

@@ -191,10 +191,9 @@ def pytest_terminal_summary(
console = Console(legacy_windows=False, force_terminal=True) console = Console(legacy_windows=False, force_terminal=True)
if diffs: if diffs:
snapshot_report_location = config._textual_snapshot_html_report snapshot_report_location = config._textual_snapshot_html_report
console.rule("[b red]Textual Snapshot Report", style="red") console.print("[b red]Textual Snapshot Report", style="red")
console.print( console.print(
f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n"
f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n" f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n"
) )
console.print(f"[dim]{snapshot_report_location}\n") console.print(f"[dim]{snapshot_report_location}\n")
console.rule(style="red")

View File

@@ -2,7 +2,8 @@ from string import ascii_lowercase
import pytest import pytest
from textual.binding import Bindings, Binding, BindingError, NoBinding from textual.app import App
from textual.binding import Bindings, Binding, BindingError, NoBinding, InvalidBinding
BINDING1 = Binding("a,b", action="action1", description="description1") BINDING1 = Binding("a,b", action="action1", description="description1")
BINDING2 = Binding("c", action="action2", description="description2") BINDING2 = Binding("c", action="action2", description="description2")
@@ -13,45 +14,78 @@ BINDING3 = Binding(" d , e ", action="action3", description="description3")
def bindings(): def bindings():
yield Bindings([BINDING1, BINDING2]) yield Bindings([BINDING1, BINDING2])
@pytest.fixture @pytest.fixture
def more_bindings(): def more_bindings():
yield Bindings([BINDING1, BINDING2, BINDING3]) yield Bindings([BINDING1, BINDING2, BINDING3])
def test_bindings_get_key(bindings): def test_bindings_get_key(bindings):
assert bindings.get_key("b") == Binding("b", action="action1", description="description1") assert bindings.get_key("b") == Binding(
"b", action="action1", description="description1"
)
assert bindings.get_key("c") == BINDING2 assert bindings.get_key("c") == BINDING2
with pytest.raises(NoBinding): with pytest.raises(NoBinding):
bindings.get_key("control+meta+alt+shift+super+hyper+t") bindings.get_key("control+meta+alt+shift+super+hyper+t")
def test_bindings_get_key_spaced_list(more_bindings): def test_bindings_get_key_spaced_list(more_bindings):
assert more_bindings.get_key("d").action == more_bindings.get_key("e").action assert more_bindings.get_key("d").action == more_bindings.get_key("e").action
def test_bindings_merge_simple(bindings): def test_bindings_merge_simple(bindings):
left = Bindings([BINDING1]) left = Bindings([BINDING1])
right = Bindings([BINDING2]) right = Bindings([BINDING2])
assert Bindings.merge([left, right]).keys == bindings.keys assert Bindings.merge([left, right]).keys == bindings.keys
def test_bindings_merge_overlap(): def test_bindings_merge_overlap():
left = Bindings([BINDING1]) left = Bindings([BINDING1])
another_binding = Binding("a", action="another_action", description="another_description") another_binding = Binding(
"a", action="another_action", description="another_description"
)
assert Bindings.merge([left, Bindings([another_binding])]).keys == { assert Bindings.merge([left, Bindings([another_binding])]).keys == {
"a": another_binding, "a": another_binding,
"b": Binding("b", action="action1", description="description1"), "b": Binding("b", action="action1", description="description1"),
} }
def test_bad_binding_tuple(): def test_bad_binding_tuple():
with pytest.raises(BindingError): with pytest.raises(BindingError):
_ = Bindings((("a", "action"),)) _ = Bindings((("a", "action"),))
with pytest.raises(BindingError): with pytest.raises(BindingError):
_ = Bindings((("a", "action", "description","too much"),)) _ = Bindings((("a", "action", "description", "too much"),))
def test_binding_from_tuples(): def test_binding_from_tuples():
assert Bindings((( BINDING2.key, BINDING2.action, BINDING2.description),)).get_key("c") == BINDING2 assert (
Bindings(((BINDING2.key, BINDING2.action, BINDING2.description),)).get_key("c")
== BINDING2
)
def test_shown(): def test_shown():
bindings = Bindings([ bindings = Bindings(
Binding( [
key, action=f"action_{key}", description=f"Emits {key}",show=bool(ord(key)%2) Binding(
) for key in ascii_lowercase key,
]) action=f"action_{key}",
assert len(bindings.shown_keys)==(len(ascii_lowercase)/2) description=f"Emits {key}",
show=bool(ord(key) % 2),
)
for key in ascii_lowercase
]
)
assert len(bindings.shown_keys) == (len(ascii_lowercase) / 2)
def test_invalid_binding():
with pytest.raises(InvalidBinding):
class BrokenApp(App):
BINDINGS = [(",,,", "foo", "Broken")]
with pytest.raises(InvalidBinding):
class BrokenApp(App):
BINDINGS = [(", ,", "foo", "Broken")]

View File

@@ -57,7 +57,7 @@ async def test_just_app_no_bindings() -> None:
class AlphaBinding(App[None]): class AlphaBinding(App[None]):
"""An app with a simple alpha key binding.""" """An app with a simple alpha key binding."""
BINDINGS = [Binding("a", "a", "a")] BINDINGS = [Binding("a", "a", "a", priority=True)]
async def test_just_app_alpha_binding() -> None: async def test_just_app_alpha_binding() -> None:
@@ -81,8 +81,7 @@ async def test_just_app_alpha_binding() -> None:
class LowAlphaBinding(App[None]): class LowAlphaBinding(App[None]):
"""An app with a simple low-priority alpha key binding.""" """An app with a simple low-priority alpha key binding."""
PRIORITY_BINDINGS = False BINDINGS = [Binding("a", "a", "a", priority=False)]
BINDINGS = [Binding("a", "a", "a")]
async def test_just_app_low_priority_alpha_binding() -> None: async def test_just_app_low_priority_alpha_binding() -> None:
@@ -106,7 +105,7 @@ async def test_just_app_low_priority_alpha_binding() -> None:
class ScreenWithBindings(Screen): class ScreenWithBindings(Screen):
"""A screen with a simple alpha key binding.""" """A screen with a simple alpha key binding."""
BINDINGS = [Binding("a", "a", "a")] BINDINGS = [Binding("a", "a", "a", priority=True)]
class AppWithScreenThatHasABinding(App[None]): class AppWithScreenThatHasABinding(App[None]):
@@ -144,8 +143,7 @@ async def test_app_screen_with_bindings() -> None:
class ScreenWithLowBindings(Screen): class ScreenWithLowBindings(Screen):
"""A screen with a simple low-priority alpha key binding.""" """A screen with a simple low-priority alpha key binding."""
PRIORITY_BINDINGS = False BINDINGS = [Binding("a", "a", "a", priority=False)]
BINDINGS = [Binding("a", "a", "a")]
class AppWithScreenThatHasALowBinding(App[None]): class AppWithScreenThatHasALowBinding(App[None]):

50
tests/test_keys.py Normal file
View File

@@ -0,0 +1,50 @@
import pytest
from textual.app import App
from textual.keys import _character_to_key
@pytest.mark.parametrize(
"character,key",
[
("1", "1"),
("2", "2"),
("a", "a"),
("z", "z"),
("_", "underscore"),
(" ", "space"),
("~", "tilde"),
("?", "question_mark"),
("£", "pound_sign"),
(",", "comma"),
],
)
def test_character_to_key(character: str, key: str) -> None:
assert _character_to_key(character) == key
async def test_character_bindings():
"""Test you can bind to a character as well as a longer key name."""
counter = 0
class BindApp(App):
BINDINGS = [(".,~,space", "increment", "foo")]
def action_increment(self) -> None:
nonlocal counter
counter += 1
app = BindApp()
async with app.run_test() as pilot:
await pilot.press(".")
await pilot.pause()
assert counter == 1
await pilot.press("~")
await pilot.pause()
assert counter == 2
await pilot.press(" ")
await pilot.pause()
assert counter == 3
await pilot.press("x")
await pilot.pause()
assert counter == 3

View File

@@ -17,7 +17,7 @@ class ValidWidget(Widget):
async def test_dispatch_key_valid_key(): async def test_dispatch_key_valid_key():
widget = ValidWidget() widget = ValidWidget()
result = await widget.dispatch_key(Key(widget, key="x", char="x")) result = await widget.dispatch_key(Key(widget, key="x", character="x"))
assert result is True assert result is True
assert widget.called_by == widget.key_x assert widget.called_by == widget.key_x
@@ -26,7 +26,7 @@ async def test_dispatch_key_valid_key_alias():
"""When you press tab or ctrl+i, it comes through as a tab key event, but handlers for """When you press tab or ctrl+i, it comes through as a tab key event, but handlers for
tab and ctrl+i are both considered valid.""" tab and ctrl+i are both considered valid."""
widget = ValidWidget() widget = ValidWidget()
result = await widget.dispatch_key(Key(widget, key="tab", char="\t")) result = await widget.dispatch_key(Key(widget, key="tab", character="\t"))
assert result is True assert result is True
assert widget.called_by == widget.key_ctrl_i assert widget.called_by == widget.key_ctrl_i
@@ -52,5 +52,5 @@ async def test_dispatch_key_raises_when_conflicting_handler_aliases():
In the terminal, they're the same thing, so we fail fast via exception here.""" In the terminal, they're the same thing, so we fail fast via exception here."""
widget = DuplicateHandlersWidget() widget = DuplicateHandlersWidget()
with pytest.raises(DuplicateKeyHandlers): with pytest.raises(DuplicateKeyHandlers):
await widget.dispatch_key(Key(widget, key="tab", char="\t")) await widget.dispatch_key(Key(widget, key="tab", character="\t"))
assert widget.called_by == widget.key_tab assert widget.called_by == widget.key_tab

View File

@@ -106,7 +106,7 @@ def test_cant_match_escape_sequence_too_long(parser):
# The rest of the characters correspond to the expected key presses # The rest of the characters correspond to the expected key presses
events = events[1:] events = events[1:]
for index, character in enumerate(sequence[1:]): for index, character in enumerate(sequence[1:]):
assert events[index].char == character assert events[index].character == character
@pytest.mark.parametrize( @pytest.mark.parametrize(