keys refactor

This commit is contained in:
Will McGugan
2022-12-20 21:18:10 +00:00
parent dd2b89bd5c
commit 5e2e0fe566
10 changed files with 94 additions and 84 deletions

View File

@@ -124,37 +124,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. Let's write an app to show key events as you type.
=== "key01.py" === "key01.py"
@@ -23,25 +23,53 @@ 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 associated 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 a number of attributes which tell you what key (or keys) have been pressed.
```{.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 letter and numbers, or a longer identifier for other keys.
Some keys may be combined with ++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 the `character` will contain a string containing 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` 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+q++ 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 key. 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.
Let's add a key method to the example code. Let's add a key method to the example code.
@@ -134,7 +162,7 @@ Note how the footer displays bindings and makes them clickable.
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 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`. 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 priority for all `BINDINGS`.
### Binding class ### Binding class

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

@@ -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,54 @@ 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. char (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 key_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, char: 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 = (key if len(key) == 1 else None) if char is None else char
self.key_aliases = [_normalize_key(alias) for alias in _get_key_aliases(key)] 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 | None:
"""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"

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

@@ -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,6 +66,7 @@ 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.
@@ -95,14 +96,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)