mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -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/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
|
||||
## [0.8.0] - Unreleased
|
||||
|
||||
### 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 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
|
||||
|
||||
- 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
|
||||
|
||||
## [0.7.0] - 2022-12-17
|
||||
|
||||
@@ -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.
|
||||
|
||||
See the previous section on [input](./input.md#bindings) for more information on bindings.
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
### 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]
|
||||
|
||||
@@ -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 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"
|
||||
|
||||
@@ -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,!,_"}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -232,8 +232,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
}
|
||||
"""
|
||||
|
||||
PRIORITY_BINDINGS = True
|
||||
|
||||
SCREENS: dict[str, Screen | Callable[[], Screen]] = {}
|
||||
_BASE_PATH: str | None = None
|
||||
CSS_PATH: CSSPathType = None
|
||||
@@ -242,10 +240,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
||||
Binding("tab", "focus_next", "Focus Next", show=False, priority=False),
|
||||
Binding(
|
||||
"shift+tab", "focus_previous", "Focus Previous", show=False, priority=False
|
||||
),
|
||||
Binding("tab", "focus_next", "Focus Next", show=False),
|
||||
Binding("shift+tab", "focus_previous", "Focus Previous", show=False),
|
||||
]
|
||||
|
||||
title: Reactive[str] = Reactive("")
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Iterable, MutableMapping
|
||||
|
||||
import rich.repr
|
||||
|
||||
from textual.keys import _character_to_key
|
||||
from textual._typing import TypeAlias
|
||||
|
||||
BindingType: TypeAlias = "Binding | tuple[str, str, str]"
|
||||
@@ -18,6 +19,10 @@ class NoBinding(Exception):
|
||||
"""A binding was not found."""
|
||||
|
||||
|
||||
class InvalidBinding(Exception):
|
||||
"""Binding key is in an invalid format."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
"""The configuration of a key binding."""
|
||||
@@ -32,8 +37,8 @@ class Binding:
|
||||
"""bool: Show the action in Footer, or False to hide."""
|
||||
key_display: str | None = None
|
||||
"""str | None: How the key should be shown in footer."""
|
||||
priority: bool | None = None
|
||||
"""bool | None: Is this a priority binding, checked form app down to focused widget?"""
|
||||
priority: bool = False
|
||||
"""bool: Enable priority binding for this key."""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -43,13 +48,11 @@ class Bindings:
|
||||
def __init__(
|
||||
self,
|
||||
bindings: Iterable[BindingType] | None = None,
|
||||
default_priority: bool | None = None,
|
||||
) -> None:
|
||||
"""Initialise a collection of bindings.
|
||||
|
||||
Args:
|
||||
bindings (Iterable[BindingType] | None, optional): An optional set of initial bindings.
|
||||
default_priority (bool | None, optional): The default priority of the bindings.
|
||||
|
||||
Note:
|
||||
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
|
||||
# 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.strip(),
|
||||
key=key,
|
||||
action=binding.action,
|
||||
description=binding.description,
|
||||
show=binding.show,
|
||||
key_display=binding.key_display,
|
||||
priority=(
|
||||
default_priority
|
||||
if binding.priority is None
|
||||
else binding.priority
|
||||
),
|
||||
priority=binding.priority,
|
||||
)
|
||||
|
||||
self.keys: MutableMapping[str, Binding] = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.\
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -92,9 +92,6 @@ class DOMNode(MessagePump):
|
||||
# Virtual DOM nodes
|
||||
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
|
||||
BINDINGS: ClassVar[list[BindingType]] = []
|
||||
|
||||
@@ -233,13 +230,13 @@ class DOMNode(MessagePump):
|
||||
|
||||
for base in reversed(cls.__mro__):
|
||||
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:
|
||||
bindings.clear()
|
||||
bindings.append(Bindings(base.__dict__.get("BINDINGS", []), priority))
|
||||
bindings.append(
|
||||
Bindings(
|
||||
base.__dict__.get("BINDINGS", []),
|
||||
)
|
||||
)
|
||||
keys = {}
|
||||
for bindings_ in bindings:
|
||||
keys.update(bindings_.keys)
|
||||
|
||||
@@ -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,56 @@ 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.
|
||||
character (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
|
||||
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)
|
||||
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 character is None else character
|
||||
)
|
||||
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:
|
||||
"""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
|
||||
|
||||
@@ -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"
|
||||
@@ -256,3 +256,14 @@ def _get_key_display(key: str) -> str:
|
||||
if unicode_character.isprintable():
|
||||
return unicode_character
|
||||
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
|
||||
|
||||
@@ -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__()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -29,7 +29,6 @@ class Screen(Widget):
|
||||
# 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
|
||||
# priority.
|
||||
PRIORITY_BINDINGS = True
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Screen {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -66,13 +66,15 @@ 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.
|
||||
|
||||
Args:
|
||||
content (RenderableType): Rich renderable (or text).
|
||||
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`.
|
||||
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`. Defaults to `False`.
|
||||
shrink (bool): Enable shrinking of content to fit width. Defaults to `True`.
|
||||
"""
|
||||
|
||||
renderable: RenderableType
|
||||
@@ -95,15 +97,18 @@ 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)
|
||||
)
|
||||
|
||||
@@ -191,10 +191,9 @@ def pytest_terminal_summary(
|
||||
console = Console(legacy_windows=False, force_terminal=True)
|
||||
if diffs:
|
||||
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(
|
||||
f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n"
|
||||
f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n"
|
||||
)
|
||||
console.print(f"[dim]{snapshot_report_location}\n")
|
||||
console.rule(style="red")
|
||||
|
||||
@@ -2,7 +2,8 @@ from string import ascii_lowercase
|
||||
|
||||
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")
|
||||
BINDING2 = Binding("c", action="action2", description="description2")
|
||||
@@ -13,45 +14,78 @@ BINDING3 = Binding(" d , e ", action="action3", description="description3")
|
||||
def bindings():
|
||||
yield Bindings([BINDING1, BINDING2])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def more_bindings():
|
||||
yield Bindings([BINDING1, BINDING2, BINDING3])
|
||||
|
||||
|
||||
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
|
||||
with pytest.raises(NoBinding):
|
||||
bindings.get_key("control+meta+alt+shift+super+hyper+t")
|
||||
|
||||
|
||||
def test_bindings_get_key_spaced_list(more_bindings):
|
||||
assert more_bindings.get_key("d").action == more_bindings.get_key("e").action
|
||||
|
||||
|
||||
def test_bindings_merge_simple(bindings):
|
||||
left = Bindings([BINDING1])
|
||||
right = Bindings([BINDING2])
|
||||
assert Bindings.merge([left, right]).keys == bindings.keys
|
||||
|
||||
|
||||
def test_bindings_merge_overlap():
|
||||
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 == {
|
||||
"a": another_binding,
|
||||
"b": Binding("b", action="action1", description="description1"),
|
||||
}
|
||||
|
||||
|
||||
def test_bad_binding_tuple():
|
||||
with pytest.raises(BindingError):
|
||||
_ = Bindings((("a", "action"),))
|
||||
with pytest.raises(BindingError):
|
||||
_ = Bindings((("a", "action", "description","too much"),))
|
||||
_ = Bindings((("a", "action", "description", "too much"),))
|
||||
|
||||
|
||||
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():
|
||||
bindings = Bindings([
|
||||
bindings = Bindings(
|
||||
[
|
||||
Binding(
|
||||
key, action=f"action_{key}", description=f"Emits {key}",show=bool(ord(key)%2)
|
||||
) for key in ascii_lowercase
|
||||
])
|
||||
assert len(bindings.shown_keys)==(len(ascii_lowercase)/2)
|
||||
key,
|
||||
action=f"action_{key}",
|
||||
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")]
|
||||
|
||||
@@ -57,7 +57,7 @@ async def test_just_app_no_bindings() -> None:
|
||||
class AlphaBinding(App[None]):
|
||||
"""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:
|
||||
@@ -81,8 +81,7 @@ async def test_just_app_alpha_binding() -> None:
|
||||
class LowAlphaBinding(App[None]):
|
||||
"""An app with a simple low-priority alpha key binding."""
|
||||
|
||||
PRIORITY_BINDINGS = False
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
BINDINGS = [Binding("a", "a", "a", priority=False)]
|
||||
|
||||
|
||||
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):
|
||||
"""A screen with a simple alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
BINDINGS = [Binding("a", "a", "a", priority=True)]
|
||||
|
||||
|
||||
class AppWithScreenThatHasABinding(App[None]):
|
||||
@@ -144,8 +143,7 @@ async def test_app_screen_with_bindings() -> None:
|
||||
class ScreenWithLowBindings(Screen):
|
||||
"""A screen with a simple low-priority alpha key binding."""
|
||||
|
||||
PRIORITY_BINDINGS = False
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
BINDINGS = [Binding("a", "a", "a", priority=False)]
|
||||
|
||||
|
||||
class AppWithScreenThatHasALowBinding(App[None]):
|
||||
|
||||
50
tests/test_keys.py
Normal file
50
tests/test_keys.py
Normal 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
|
||||
@@ -17,7 +17,7 @@ class ValidWidget(Widget):
|
||||
|
||||
async def test_dispatch_key_valid_key():
|
||||
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 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
|
||||
tab and ctrl+i are both considered valid."""
|
||||
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 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."""
|
||||
widget = DuplicateHandlersWidget()
|
||||
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
|
||||
|
||||
@@ -106,7 +106,7 @@ def test_cant_match_escape_sequence_too_long(parser):
|
||||
# The rest of the characters correspond to the expected key presses
|
||||
events = events[1:]
|
||||
for index, character in enumerate(sequence[1:]):
|
||||
assert events[index].char == character
|
||||
assert events[index].character == character
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
Reference in New Issue
Block a user