From 5e2e0fe5660eb9b7042cd31a3cfe38c7f28d1092 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Dec 2022 21:18:10 +0000 Subject: [PATCH] keys refactor --- docs/guide/actions.md | 40 ++++--------------------- docs/guide/input.md | 50 +++++++++++++++++++++++++------- src/textual/cli/cli.py | 2 +- src/textual/cli/previews/keys.py | 2 +- src/textual/events.py | 40 ++++++++++++++----------- src/textual/keys.py | 8 ++--- src/textual/message.py | 6 ++-- src/textual/message_pump.py | 6 ++-- src/textual/widgets/_input.py | 4 +-- src/textual/widgets/_text_log.py | 20 ++++++++----- 10 files changed, 94 insertions(+), 84 deletions(-) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 063986069..72293bc02 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -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. - -### Bell - -::: textual.app.App.action_bell - options: - show_root_heading: false - -### 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 +- [action_bell][textual.app.App.action_bell] +- [action_push_screen][textual.app.App.action_push_screen] +- [action_pop_screen][textual.app.App.action_pop_screen] +- [action_switch_screen][textual.app.App.action_switch_screen] +- [action_screenshot][textual.app.App.action_screenshot] +- [action_toggle_dark][textual.app.App.action_toggle_dark] diff --git a/docs/guide/input.md b/docs/guide/input.md index 122599ae7..44892743a 100644 --- a/docs/guide/input.md +++ b/docs/guide/input.md @@ -10,7 +10,7 @@ This chapter will discuss how to make your app respond to input in the form of k ## 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" @@ -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,!,_"} ``` -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 -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. @@ -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 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 diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index e3f93c4ca..9113338f1 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -127,7 +127,7 @@ def colors(): @run.command("keys") def keys(): - """Show key events""" + """Show key events.""" from textual.cli.previews import keys keys.app.run() diff --git a/src/textual/cli/previews/keys.py b/src/textual/cli/previews/keys.py index 4facbcb4a..a3b30e162 100644 --- a/src/textual/cli/previews/keys.py +++ b/src/textual/cli/previews/keys.py @@ -9,7 +9,7 @@ from textual.widgets import Button, Header, TextLog INSTRUCTIONS = """\ 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.\ """ diff --git a/src/textual/events.py b/src/textual/events.py index 96602a0bd..4a3e880a1 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: from .timer import Timer as TimerClass from .timer import TimerCallback from .widget import Widget - import asyncio @rich.repr.auto @@ -183,7 +182,7 @@ class MouseRelease(Event, bubble=False): class InputEvent(Event): - pass + """Base class for input events.""" @rich.repr.auto @@ -191,47 +190,54 @@ class Key(InputEvent): """Sent when the user hits a key on the keyboard. Args: - sender (MessageTarget): The sender of the event (the App). - key (str): A key name (textual.keys.Keys). - char (str | None, optional): A printable character or None if it is not printable. + sender (MessageTarget): The sender of the event (always the App). + key (str): The key that was pressed. + char (str | None, optional): A printable character or ``None`` if it is not printable. Attributes: 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: super().__init__(sender) self.key = key - self.char = (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.character = (key if len(key) == 1 else None) if char is None else char + self.aliases = _get_key_aliases(key) def __rich_repr__(self) -> rich.repr.Result: yield "key", self.key - yield "char", self.char + yield "character", self.character + yield "name", self.name yield "is_printable", self.is_printable - yield "key_aliases", self.key_aliases, [self.key_name] + yield "aliases", self.aliases, [self.key] @property - def key_name(self) -> str | None: + def name(self) -> str | None: """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 def is_printable(self) -> bool: - """Return True if the key is printable. Currently, we assume any key event that - isn't defined in key bindings is printable. + """Check if the key is printable (produces a unicode character). Returns: 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.""" - return key.replace("+", "_") + if len(key) == 1 and key.isupper(): + key = f"upper_{key.lower()}" + return key.replace("+", "_").lower() @rich.repr.auto diff --git a/src/textual/keys.py b/src/textual/keys.py index 25fc148df..4656895ae 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -70,10 +70,10 @@ class Keys(str, Enum): ControlShift9 = "ctrl+shift+9" ControlShift0 = "ctrl+shift+0" - ControlBackslash = "ctrl+\\" - ControlSquareClose = "ctrl+]" - ControlCircumflex = "ctrl+^" - ControlUnderscore = "ctrl+_" + ControlBackslash = "ctrl+backslash" + ControlSquareClose = "ctrl+right_square_bracked" + ControlCircumflex = "ctrl+circumflex_accent" + ControlUnderscore = "ctrl+underscore" Left = "left" Right = "right" diff --git a/src/textual/message.py b/src/textual/message.py index aafe13dea..45c0b2d67 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -24,7 +24,6 @@ class Message: __slots__ = [ "sender", - "name", "time", "_forwarded", "_no_default_action", @@ -40,13 +39,14 @@ class Message: def __init__(self, sender: MessageTarget) -> None: self.sender = sender - self.name = camel_to_snake(self.__class__.__name__) + self.time = _clock.get_time_no_wait() self._forwarded = False self._no_default_action = False self._stop_propagation = False + name = camel_to_snake(self.__class__.__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__() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 9d28f738b..5c3fca3f6 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -593,12 +593,12 @@ class MessagePump(metaclass=MessagePumpMeta): handled = False invoked_method = None - key_name = event.key_name + key_name = event.name if not key_name: return False - for key_alias in event.key_aliases: - key_method = get_key_handler(self, key_alias) + for key_method_name in event.name_aliases: + key_method = get_key_handler(self, key_method_name) if key_method is not None: if invoked_method: _raise_duplicate_key_handlers_error( diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 6434d321d..c2bfa638a 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -233,8 +233,8 @@ class Input(Widget, can_focus=True): return elif event.is_printable: event.stop() - assert event.char is not None - self.insert_text_at_cursor(event.char) + assert event.character is not None + self.insert_text_at_cursor(event.character) event.prevent_default() def on_paste(self, event: events.Paste) -> None: diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index f3338fa8d..667381d5a 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -66,6 +66,7 @@ class TextLog(ScrollView, can_focus=True): content: RenderableType | object, width: int | None = None, expand: bool = False, + shrink: bool = True, ) -> None: """Write text or a rich renderable. @@ -95,14 +96,17 @@ class TextLog(ScrollView, can_focus=True): if isinstance(renderable, Text) and not self.wrap: render_options = render_options.update(overflow="ignore", no_wrap=True) - if expand: - render_width = self.scrollable_content_region.width - else: - render_width = ( - measure_renderables(console, render_options, [renderable]).maximum - if width is None - else width - ) + render_width = measure_renderables( + console, render_options, [renderable] + ).maximum + container_width = ( + self.scrollable_content_region.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( renderable, render_options.update_width(render_width)