Merge pull request #801 from Textualize/docs-input

Docs input
This commit is contained in:
Will McGugan
2022-09-27 09:28:24 +01:00
committed by GitHub
57 changed files with 754 additions and 74 deletions

View File

@@ -8,3 +8,7 @@ The `Blur` event is sent to a widget when it loses focus.
## Attributes
_No other attributes_
## Code
::: textual.events.Blur

View File

@@ -16,6 +16,10 @@ The `Click` event is sent to a widget when the user clicks a mouse button.
| `button` | int | Index of mouse button |
| `shift` | bool | Shift key pressed if True |
| `meta` | bool | Meta key pressed if True |
| `ctrl` | bool | Ctrl key pressed if True |
| `ctrl` | bool | Ctrl key pressed if True |
| `screen_x` | int | Mouse x coordinate relative to the screen |
| `screen_y` | int | Mouse y coordinate relative to the screen |
## Code
::: textual.events.Click

View File

@@ -8,3 +8,7 @@ The `DescendantBlur` event is sent to a widget when one of its children loses fo
## Attributes
_No other attributes_
## Code
::: textual.events.DescendantBlur

View File

@@ -8,3 +8,7 @@ The `DescendantFocus` event is sent to a widget when one of its descendants rece
## Attributes
_No other attributes_
## Code
::: textual.events.DescendantFocus

View File

@@ -8,3 +8,7 @@ The `Enter` event is sent to a widget when the mouse pointer first moves over a
## Attributes
_No other attributes_
## Code
::: textual.events.Enter

View File

@@ -8,3 +8,7 @@ The `Focus` event is sent to a widget when it receives input focus.
## Attributes
_No other attributes_
## Code
::: textual.events.Focus

View File

@@ -8,3 +8,7 @@ The `Hide` event is sent to a widget when it is hidden from view.
## Attributes
_No additional attributes_
## Code
::: textual.events.Hide

View File

@@ -7,6 +7,11 @@ The `Key` event is sent to a widget when the user presses a key on the keyboard.
## Attributes
| attribute | type | purpose |
| --------- | ---- | ------------------------ |
| `key` | str | The key that was pressed |
| attribute | type | purpose |
| --------- | ----------- | ----------------------------------------------------------- |
| `key` | str | Name of the key that was pressed. |
| `char` | str or None | The character that was pressed, or None it isn't printable. |
## Code
::: textual.events.Key

View File

@@ -8,3 +8,7 @@ The `Leave` event is sent to a widget when the mouse pointer moves off a widget.
## Attributes
_No other attributes_
## Code
::: textual.events.Leave

View File

@@ -10,3 +10,7 @@ The load event is typically used to do any setup actions required by the app tha
## Attributes
_No additional attributes_
## Code
::: textual.events.Load

View File

@@ -10,3 +10,7 @@ The mount event is typically used to set the initial state of a widget or to add
## Attributes
_No additional attributes_
## Code
::: textual.events.Mount

View File

@@ -10,3 +10,7 @@ The `MouseCapture` event is sent to a widget when it is capturing mouse events f
| attribute | type | purpose |
| ---------------- | ------ | --------------------------------------------- |
| `mouse_position` | Offset | Mouse coordinates when the mouse was captured |
## Code
::: textual.events.MouseCapture

View File

@@ -19,3 +19,7 @@ The `MouseDown` event is sent to a widget when a mouse button is pressed.
| `ctrl` | bool | Ctrl key pressed if True |
| `screen_x` | int | Mouse x coordinate relative to the screen |
| `screen_y` | int | Mouse y coordinate relative to the screen |
## Code
::: textual.events.MouseDown

View File

@@ -2,7 +2,7 @@
The `MouseMove` event is sent to a widget when the mouse pointer is moved over a widget.
- [x] Bubbles
- [ ] Bubbles
- [x] Verbose
## Attributes
@@ -19,3 +19,7 @@ The `MouseMove` event is sent to a widget when the mouse pointer is moved over a
| `ctrl` | bool | Ctrl key pressed if True |
| `screen_x` | int | Mouse x coordinate relative to the screen |
| `screen_y` | int | Mouse y coordinate relative to the screen |
## Code
::: textual.events.MouseMove

View File

@@ -7,6 +7,10 @@ The `MouseRelease` event is sent to a widget when it is no longer receiving mous
## Attributes
| attribute | type | purpose |
| ---------------- | ------ | -------------------------------------------- |
| attribute | type | purpose |
| ---------------- | ------ | --------------------------------------------- |
| `mouse_position` | Offset | Mouse coordinates when the mouse was released |
## Code
::: textual.events.MouseRelease

View File

@@ -8,6 +8,10 @@ The `MouseScrollDown` event is sent to a widget when the scroll wheel (or trackp
## Attributes
| attribute | type | purpose |
|-----------|------|----------------------------------------|
| --------- | ---- | -------------------------------------- |
| `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget |
## Code
::: textual.events.MouseScrollDown

View File

@@ -11,3 +11,7 @@ The `MouseScrollUp` event is sent to a widget when the scroll wheel (or trackpad
| --------- | ---- | -------------------------------------- |
| `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget |
## Code
::: textual.events.MouseScrollUp

View File

@@ -19,3 +19,7 @@ The `MouseUp` event is sent to a widget when the user releases a mouse button.
| `ctrl` | bool | Ctrl key pressed if True |
| `screen_x` | int | Mouse x coordinate relative to the screen |
| `screen_y` | int | Mouse y coordinate relative to the screen |
## Code
::: textual.events.MouseUp

View File

@@ -10,3 +10,7 @@ The `Paste` event is sent to a widget when the user pastes text.
| attribute | type | purpose |
| --------- | ---- | ------------------------ |
| `text` | str | The text that was pasted |
## Code
::: textual.events.Paste

View File

@@ -7,8 +7,12 @@ The `Resize` event is sent to a widget when its size changes and when it is firs
## Attributes
| attribute | type | purpose |
| ---------------- | ---- | ------------------------------------------------- |
| attribute | type | purpose |
| ---------------- | ---- | ------------------------------------------------ |
| `size` | Size | The new size of the Widget |
| `virtual_size` | Size | The virtual size (scrollable area) of the Widget |
| `container_size` | Size | The size of the container (parent widget) |
## Code
::: textual.events.Resize

View File

@@ -8,3 +8,7 @@ The `ScreenResume` event is sent to a **Screen** when it becomes current.
## Attributes
_No other attributes_
## Code
::: textual.events.ScreenResume

View File

@@ -8,3 +8,7 @@ The `ScreenSuspend` event is sent to a **Screen** when it is replaced by another
## Attributes
_No other attributes_
## Code
::: textual.events.ScreenSuspend

View File

@@ -8,3 +8,7 @@ The `Show` event is sent to a widget when it becomes visible.
## Attributes
_No additional attributes_
## Code
::: textual.events.Show

View File

@@ -0,0 +1,7 @@
Bar {
height: 5;
content-align: center middle;
text-style: bold;
margin: 1 2;
color: $text;
}

View File

@@ -0,0 +1,31 @@
from textual.app import App, ComposeResult
from textual.color import Color
from textual.widgets import Footer, Static
class Bar(Static):
pass
class BindingApp(App):
CSS_PATH = "binding01.css"
BINDINGS = [
("r", "add_bar('red')", "Add Red"),
("g", "add_bar('green')", "Add Green"),
("b", "add_bar('blue')", "Add Blue"),
]
def compose(self) -> ComposeResult:
yield Footer()
def action_add_bar(self, color: str) -> None:
bar = Bar(color)
bar.styles.background = Color.parse(color).with_alpha(0.5)
self.mount(bar)
self.call_later(self.screen.scroll_end, animate=False)
if __name__ == "__main__":
app = BindingApp()
app.run()

View File

@@ -0,0 +1,21 @@
from textual.app import App, ComposeResult
from textual.widgets import TextLog
from textual import events
class InputApp(App):
"""App to display key events."""
def compose(self) -> ComposeResult:
yield TextLog()
def on_key(self, event: events.Key) -> None:
self.query_one(TextLog).write(event)
def key_space(self) -> None:
self.bell()
if __name__ == "__main__":
app = InputApp()
app.run()

View File

@@ -0,0 +1,21 @@
from textual.app import App, ComposeResult
from textual.widgets import TextLog
from textual import events
class InputApp(App):
"""App to display key events."""
def compose(self) -> ComposeResult:
yield TextLog()
def on_key(self, event: events.Key) -> None:
self.query_one(TextLog).write(event)
def key_space(self) -> None:
self.bell()
if __name__ == "__main__":
app = InputApp()
app.run()

View File

@@ -0,0 +1,17 @@
Screen {
layout: grid;
grid-size: 2 2;
grid-columns: 1fr;
}
KeyLogger {
border: blank;
}
KeyLogger:hover {
border: wide $secondary;
}
KeyLogger:focus {
border: wide $accent;
}

View File

@@ -0,0 +1,25 @@
from textual.app import App, ComposeResult
from textual.widgets import TextLog
from textual import events
class KeyLogger(TextLog):
def on_key(self, event: events.Key) -> None:
self.write(event)
class InputApp(App):
"""App to display key events."""
CSS_PATH = "key03.css"
def compose(self) -> ComposeResult:
yield KeyLogger()
yield KeyLogger()
yield KeyLogger()
yield KeyLogger()
if __name__ == "__main__":
app = InputApp()
app.run()

View File

@@ -0,0 +1,24 @@
Screen {
layers: log ball;
}
TextLog {
layer: log;
}
PlayArea {
background: transparent;
layer: ball;
}
Ball {
layer: ball;
width: auto;
height: 1;
background: $secondary;
border: tall $secondary;
color: $background;
box-sizing: content-box;
text-style: bold;
padding: 0 4;
}

View File

@@ -0,0 +1,30 @@
from textual import events
from textual.app import App, ComposeResult
from textual.layout import Container
from textual.widgets import Static, TextLog
class PlayArea(Container):
def on_mount(self) -> None:
self.capture_mouse()
def on_mouse_move(self, event: events.MouseMove) -> None:
self.screen.query_one(TextLog).write(event)
self.query_one(Ball).offset = event.offset - (8, 2)
class Ball(Static):
pass
class MouseApp(App):
CSS_PATH = "mouse01.css"
def compose(self) -> ComposeResult:
yield TextLog()
yield PlayArea(Ball("Textual"))
if __name__ == "__main__":
app = MouseApp()
app.run()

View File

@@ -25,7 +25,7 @@ You can install Textual via PyPI.
If you plan on developing Textual apps, then you should install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development.
```bash
pip install textual[dev]
pip install "textual[dev]"
```
If you only plan on _running_ Textual apps, then you can drop the `[dev]` part:

202
docs/guide/input.md Normal file
View File

@@ -0,0 +1,202 @@
# Input
This chapter will discuss how to make your app respond to input in the form of key presses and mouse actions.
!!! quote
More Input!
— Johnny Five
## Keyboard input
The most fundamental way to receive input in via [Key](./events/key) events. Let's write an app to show key events as you type.
=== "key01.py"
```python title="key01.py" hl_lines="12-13"
--8<-- "docs/examples/guide/input/key01.py"
```
=== "Output"
```{.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.
### Attributes
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 insures that the `key` attribute could always be used in a method name.
Key events also contain a `char` attribute which contains single character if it is printable, or ``None`` if it is not printable (like a function key which has no corresponding character).
To illustrate the difference between `key` ad `char`, try `key01.py` with the space key. You should see something like the following:
```{.textual path="docs/examples/guide/input/key01.py", press="space,_"}
```
Note that he `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.
Let's add a key method to the example code.
```python title="key02.py" hl_lines="15-16"
--8<-- "docs/examples/guide/input/key01.py"
```
Note the addition of a `key_space` method which is called in response to the space key, and plays the terminal bell noise.
!!! note
Consider key methods to be a convenience for experimenting with Textual features. In nearly all cases, key [bindings](#bindings) and [actions](../guide/actions.md) are preferable.
## Input focus
Only a single widget may receive key events at a time. The widget which is actively receiving key events is said to have input _focus_.
The following example shows how focus works in practice.
=== "key03.py"
```python title="key03.py" hl_lines="16-20"
--8<-- "docs/examples/guide/input/key03.py"
```
=== "key03.css"
```python title="key03.css" hl_lines="15-17"
--8<-- "docs/examples/guide/input/key03.css"
```
=== "Output"
```{.textual path="docs/examples/guide/input/key03.py", press="tab,H,e,l,l,o,tab,W,o,r,l,d,!,_"}
```
The app splits the screen in to quarters, with a TextLog widget in each quarter. If you click any of the text logs, you should see that it is highlighted to show that thw widget has focus. Key events will be sent to the focused widget only.
!!! tip
the `:focus` CSS pseudo-selector can be used to apply a style to the focused widget.
You can move focus by pressing the ++tab++ key to focus the next widget. Pressing ++shift+tab++ moves the focus in the opposite direction.
### Controlling focus
Textual will handle keyboard focus automatically, but you can tell Textual to focus a widget by calling the widget's [focus()][textual.widget.Widget.focus] method.
### Focus events
When a widget receives focus, it is sent a [Focus](../events/focus.md) event. When a widget loses focus it is sent a [Blur](../events/blur.md) event.
## Bindings
Keys may be associated with [actions](../guide/actions.md) for a given widget. This association is known as a key _binding_.
To create bindings, add a `BINDINGS` class variable to your app or widget. This should be a list of tuples of three strings. The first value is the key, the second is the action, the third value is a short human readable description.
The following example binds the keys ++r++, ++g++, and ++b++ to an action which adds a bar widget to the screen.
=== "binding01.py"
```python title="binding01.py" hl_lines="13-17"
--8<-- "docs/examples/guide/input/binding01.py"
```
=== "binding01.css"
```python title="binding01.css"
--8<-- "docs/examples/guide/input/binding01.css"
```
=== "Output"
```{.textual path="docs/examples/guide/input/binding01.py", press="r,g,b,b"}
```
Note how the footer displays bindings and makes them clickable.
### 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?
Bindings are particularly useful for configurable hot-keys. Bindings can also be inspected in widgets such as [Footer](../widgets/footer.md).
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
Textual will send events in response to mouse movement and mouse clicks. These events contain the coordinates of the mouse cursor relative to the terminal or widget.
!!! information
The trackpad (and possibly other pointer devices) are treated the same as the mouse in terminals.
Terminal coordinates are given by a pair values named `x` and `y`. The X coordinate is an offset in characters, extending from the left to the right of the screen. The Y coordinate is an offset in _lines_, extending from the top of the screen to the bottom.
Coordinates may be relative to the screen, so `(0, 0)` would be the top left of the screen. Coordinates may also be relative to a widget, where `(0, 0)` would be the top left of the widget itself.
<div class="excalidraw">
--8<-- "docs/images/input/coords.excalidraw.svg"
</div>
### Mouse movements
When you move the mouse cursor over a widget it will receive [MouseMove](../events/mouse_move.md) events which contain the coordinate of the mouse and information about what modified keys (++ctrl++, ++shift++ etc).
The following example shows mouse movements being used to _attach_ a widget to the mouse cursor.
=== "mouse01.py"
```python title="mouse01.py" hl_lines="11-13"
--8<-- "docs/examples/guide/input/mouse01.py"
```
=== "mouse01.css"
```python title="mouse01.css"
--8<-- "docs/examples/guide/input/mouse01.css"
```
If you run `mouse01.py` you should find that it logs the mouse move event, and keeps a widget pinned directly under the cursor.
The `on_mouse_move` handler sets the [offset](../styles/offset.md) style of the ball (a rectangular one) to match the mouse coordinates.
### Mouse capture
In the `mouse01.py` example there was a call to `capture_mouse()` in the mount handler. Textual will send mouse move events to the widget directly under the cursor. You can tell Textual to send all mouse events to a widget regardless of the position of the mouse cursor by calling [capture_mouse][textual.widget.Widget.capture_mouse].
Call [release_mouse][textual.widget.Widget.release_mouse] to restore the default behavior.
!!! warning
If you capture the mouse, be aware you might get negative mouse coordinates if the cursor is to the left of the widget.
Textual will send a [MouseCapture](../events/mouse_capture.md) event when the mouse is captured, and a [MouseRelease](../events/mouse_release.md) event when it is released.
### Enter and Leave events
Textual will send a [Enter](../events/enter.md) event to a widget when the mouse cursor first moves over it, and a [Leave](../events/leave) event when the cursor moves off a widget.
### Click events
There are three events associated with clicking a button on your mouse. When the button is initially pressed, Textual sends a [MouseDown](../events/mouse_down.md) event, followed by [MouseUp](../events/mouse_up.md) when the button is released. Textual then sends a final [Click](../events/mouse_click.md) event.
If you want your app to respond to a mouse click you should prefer the Click event (and not MouseDown or MouseUp). This is because a future version of Textual may support other pointing devices which don't have up and down states.
### Scroll events
Most mice have a scroll wheel which you can use to scroll window underneath the cursor. Scrollable containers in Textual will handle these automatically, but you can handle [MouseDown](../events/mouse_scroll_down.md) and [MouseUp](../events/mouse_scroll_up) if you want build your own scrolling functionality.
!!! information
Terminal emulators will typically convert trackpad gestures in to scroll events.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1 @@
::: textual.binding.Binding

View File

@@ -75,7 +75,7 @@ Return types follow `->`. So `-> str:` indicates this method returns a string.
## The App class
The first step in building a Textual app is to import and extend the `App` class. Here's our basic app class with a few methods we will cover below.
The first step in building a Textual app is to import and extend the `App` class. Here's a basic app class we will use as a starting point for the stopwatch app.
```python title="stopwatch01.py"
--8<-- "docs/examples/tutorial/stopwatch01.py"
@@ -114,9 +114,9 @@ The App class is where most of the logic of Textual apps is written. It is respo
Here's what the above app defines:
- `BINDINGS` is a list of tuples that maps (or *binds*) keys to actions in your app. The first value in the tuple is the key; the second value is the name of the action; the final value is a short description. The name of the action (`"toggle_dark"`) is mapped on to the `"action_toggle_dark"` method (see below) which is called when you hit the ++d++ key.
- `BINDINGS` is a list of tuples that maps (or *binds*) keys to actions in your app. The first value in the tuple is the key; the second value is the name of the action; the final value is a short description. We have a single binding which maps the ++d++ key on to the "toggle_dark" action.
- `compose()` is where we construct a user interface with widgets. The `compose()` method may return a list of widgets, but it is generally easier to _yield_ them (making this method a generator). In the example code we yield instances of the widget classes we imported, i.e. the header and the footer.
- `compose()` is where we construct a user interface with widgets. The `compose()` method may return a list of widgets, but it is generally easier to _yield_ them (making this method a generator). In the example code we yield an instance of each of the widget classes we imported, i.e. `Header()` and `Footer()`.
- `action_toggle_dark()` defines an _action_ method. Actions are methods beginning with `action_` followed by the name of the action. The `BINDINGS` list above tells Textual to run this action when the user hits the ++d++ key.
@@ -124,9 +124,7 @@ Here's what the above app defines:
--8<-- "docs/examples/tutorial/stopwatch01.py"
```
The final three lines create an instance of the app and call [run()][textual.app.App.run] method within a `__name__ == "__main__"` block. This is so we can call `python stopwatch01.py` to run the app, or we could import `stopwatch01` as part of a larger application.
It's the run method that puts the terminal in to *application mode* so that Textual can take over updating the terminal and handling keyboard and mouse input.
The final three lines create an instance of the app and call [run()][textual.app.App.run] which puts your terminal in to *application mode* and runs the app until you exit with ++ctrl+c++. This happens within a `__name__ == "__main__"` block so we could run the app with `python stopwatch01.py` or import it as part of a larger project.
## Designing a UI with widgets

View File

@@ -14,6 +14,7 @@ nav:
- "guide/CSS.md"
- "guide/layout.md"
- "guide/events.md"
- "guide/input.md"
- "guide/actions.md"
- "guide/reactivity.md"
- "guide/widgets.md"
@@ -37,7 +38,7 @@ nav:
- "events/load.md"
- "events/mount.md"
- "events/mouse_capture.md"
- "events/mouse_click.md"
- "events/click.md"
- "events/mouse_down.md"
- "events/mouse_move.md"
- "events/mouse_release.md"

View File

@@ -276,7 +276,12 @@ class Animator:
if duration is not None:
animation_duration = duration
else:
animation_duration = abs(value - start_value) / (speed or 50)
if hasattr(value, "get_distance_to"):
animation_duration = value.get_distance_to(start_value) / (
speed or 50
)
else:
animation_duration = abs(value - start_value) / (speed or 50)
animation = SimpleAnimation(
obj,

View File

@@ -15,7 +15,7 @@ ANSI_SEQUENCES_KEYS: Mapping[str, Tuple[Keys, ...]] = {
"\x05": (Keys.ControlE,), # Control-E (end)
"\x06": (Keys.ControlF,), # Control-F (cursor forward)
"\x07": (Keys.ControlG,), # Control-G
"\x08": (Keys.ControlH,), # Control-H (8) (Identical to '\b')
"\x08": (Keys.Backspace,), # Control-H (8) (Identical to '\b')
"\x09": (Keys.Tab,), # Control-I (9) (Identical to '\t')
"\x0a": (Keys.ControlJ,), # Control-J (10) (Identical to '\n')
"\x0b": (Keys.ControlK,), # Control-K (delete until end of line; vertical tab)
@@ -47,7 +47,7 @@ ANSI_SEQUENCES_KEYS: Mapping[str, Tuple[Keys, ...]] = {
# handle backspace and control-h individually for the few terminals that
# support it. (Most terminals send ControlH when backspace is pressed.)
# See: http://www.ibb.net/~anne/keyboard.html
"\x7f": (Keys.ControlH,),
"\x7f": (Keys.Backspace,),
"\x1b\x7f": (Keys.ControlW,),
# Various
"\x1b[1~": (Keys.Home,), # tmux

View File

@@ -501,7 +501,18 @@ class Compositor:
raise errors.NoWidget("Widget is not in layout")
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
"""Get the widget under the given point or None."""
"""Get the widget under a given coordinate.
Args:
x (int): X Coordinate.
y (int): Y Coordinate.
Raises:
errors.NoWidget: If there is not widget underneath (x, y).
Returns:
tuple[Widget, Region]: A tuple of the widget and its region.
"""
# TODO: Optimize with some line based lookup
contains = Region.contains
for widget, cropped_region, region, *_ in self:
@@ -509,6 +520,21 @@ class Compositor:
return widget, region
raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})")
def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
"""Get all widgets under a given coordinate.
Args:
x (int): X coordinate.
y (int): Y coordinate.
Returns:
Iterable[tuple[Widget, Region]]: Sequence of (WIDGET, REGION) tuples.
"""
contains = Region.contains
for widget, cropped_region, region, *_ in self:
if contains(cropped_region, x, y) and widget.visible:
yield widget, region
def get_style_at(self, x: int, y: int) -> Style:
"""Get the Style at the given cell or Style.null()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import unicodedata
import re
from typing import Any, Callable, Generator, Iterable
@@ -12,7 +13,7 @@ from ._types import MessageTarget
# When trying to determine whether the current sequence is a supported/valid
# escape sequence, at which length should we give up and consider our search
# to be unsuccessful?
from .keys import Keys
from .keys import KEY_NAME_REPLACEMENTS
_MAX_SEQUENCE_SEARCH_THRESHOLD = 20
@@ -101,7 +102,7 @@ class XTermParser(Parser[events.Event]):
key_events = sequence_to_key_events(character)
for event in key_events:
if event.key == "escape":
event = events.Key(event.sender, "^", None)
event = events.Key(event.sender, "circumflex_accent", "^")
on_token(event)
while not self.is_eof:
@@ -228,7 +229,9 @@ class XTermParser(Parser[events.Event]):
for event in sequence_to_key_events(character):
on_token(event)
def _sequence_to_key_events(self, sequence: str) -> Iterable[events.Key]:
def _sequence_to_key_events(
self, sequence: str, _unicode_name=unicodedata.name
) -> Iterable[events.Key]:
"""Map a sequence of code points on to a sequence of keys.
Args:
@@ -246,4 +249,17 @@ class XTermParser(Parser[events.Event]):
self.sender, key.value, sequence if len(sequence) == 1 else None
)
elif len(sequence) == 1:
yield events.Key(self.sender, sequence, sequence)
try:
if not sequence.isalnum():
name = (
_unicode_name(sequence)
.lower()
.replace("-", "_")
.replace(" ", "_")
)
else:
name = sequence
name = KEY_NAME_REPLACEMENTS.get(name, name)
yield events.Key(self.sender, name, sequence)
except:
yield events.Key(self.sender, sequence, sequence)

View File

@@ -1447,7 +1447,8 @@ class App(Generic[ReturnType], DOMNode):
elif event.key == "shift+tab":
self.focus_previous()
else:
await self.press(event.key)
if not (await self.press(event.key)):
await self.dispatch_key(event)
async def _on_shutdown_request(self, event: events.ShutdownRequest) -> None:
log("shutdown request")

View File

@@ -26,11 +26,17 @@ class NoBinding(Exception):
@dataclass
class Binding:
key: str
"""Key to bind."""
action: str
"""Action to bind to."""
description: str
"""Description of action."""
show: bool = True
"""Show the action in Footer, or False to hide."""
key_display: str | None = None
"""How the key should be shown in footer."""
allow_forward: bool = True
"""Allow forwarding from app to focused widget."""
@rich.repr.auto

View File

@@ -16,7 +16,7 @@ a method which evaluates the query, such as first() and last().
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator, TypeVar, overload
from typing import Generic, TYPE_CHECKING, Iterator, TypeVar, overload
import rich.repr
@@ -42,8 +42,11 @@ class WrongType(QueryError):
pass
QueryType = TypeVar("QueryType", bound="Widget")
@rich.repr.auto(angular=True)
class DOMQuery:
class DOMQuery(Generic[QueryType]):
__slots__ = [
"_node",
"_nodes",
@@ -78,7 +81,7 @@ class DOMQuery:
return self._node
@property
def nodes(self) -> list[Widget]:
def nodes(self) -> list[QueryType]:
"""Lazily evaluate nodes."""
from ..widget import Widget
@@ -103,21 +106,21 @@ class DOMQuery:
"""True if non-empty, otherwise False."""
return bool(self.nodes)
def __iter__(self) -> Iterator[Widget]:
def __iter__(self) -> Iterator[QueryType]:
return iter(self.nodes)
def __reversed__(self) -> Iterator[Widget]:
def __reversed__(self) -> Iterator[QueryType]:
return reversed(self.nodes)
@overload
def __getitem__(self, index: int) -> Widget:
def __getitem__(self, index: int) -> QueryType:
...
@overload
def __getitem__(self, index: slice) -> list[Widget]:
def __getitem__(self, index: slice) -> list[QueryType]:
...
def __getitem__(self, index: int | slice) -> Widget | list[Widget]:
def __getitem__(self, index: int | slice) -> QueryType | list[QueryType]:
return self.nodes[index]
def __rich_repr__(self) -> rich.repr.Result:
@@ -133,7 +136,7 @@ class DOMQuery:
for selectors in self._excludes
)
def filter(self, selector: str) -> DOMQuery:
def filter(self, selector: str) -> DOMQuery[QueryType]:
"""Filter this set by the given CSS selector.
Args:
@@ -145,7 +148,7 @@ class DOMQuery:
return DOMQuery(self.node, filter=selector, parent=self)
def exclude(self, selector: str) -> DOMQuery:
def exclude(self, selector: str) -> DOMQuery[QueryType]:
"""Exclude nodes that match a given selector.
Args:
@@ -166,7 +169,9 @@ class DOMQuery:
def first(self, expect_type: type[ExpectType]) -> ExpectType:
...
def first(self, expect_type: type[ExpectType] | None = None) -> Widget | ExpectType:
def first(
self, expect_type: type[ExpectType] | None = None
) -> QueryType | ExpectType:
"""Get the *first* match node.
Args:
@@ -199,7 +204,9 @@ class DOMQuery:
def last(self, expect_type: type[ExpectType]) -> ExpectType:
...
def last(self, expect_type: type[ExpectType] | None = None) -> Widget | ExpectType:
def last(
self, expect_type: type[ExpectType] | None = None
) -> QueryType | ExpectType:
"""Get the *last* match node.
Args:
@@ -251,7 +258,7 @@ class DOMQuery:
if isinstance(node, filter_type):
yield node
def set_class(self, add: bool, *class_names: str) -> DOMQuery:
def set_class(self, add: bool, *class_names: str) -> DOMQuery[QueryType]:
"""Set the given class name(s) according to a condition.
Args:
@@ -264,31 +271,33 @@ class DOMQuery:
node.set_class(add, *class_names)
return self
def add_class(self, *class_names: str) -> DOMQuery:
def add_class(self, *class_names: str) -> DOMQuery[QueryType]:
"""Add the given class name(s) to nodes."""
for node in self:
node.add_class(*class_names)
return self
def remove_class(self, *class_names: str) -> DOMQuery:
def remove_class(self, *class_names: str) -> DOMQuery[QueryType]:
"""Remove the given class names from the nodes."""
for node in self:
node.remove_class(*class_names)
return self
def toggle_class(self, *class_names: str) -> DOMQuery:
def toggle_class(self, *class_names: str) -> DOMQuery[QueryType]:
"""Toggle the given class names from matched nodes."""
for node in self:
node.toggle_class(*class_names)
return self
def remove(self) -> DOMQuery:
def remove(self) -> DOMQuery[QueryType]:
"""Remove matched nodes from the DOM"""
for node in self:
node.remove()
return self
def set_styles(self, css: str | None = None, **update_styles) -> DOMQuery:
def set_styles(
self, css: str | None = None, **update_styles
) -> DOMQuery[QueryType]:
"""Set styles on matched nodes.
Args:
@@ -308,7 +317,9 @@ class DOMQuery:
node.refresh(layout=True)
return self
def refresh(self, *, repaint: bool = True, layout: bool = False) -> DOMQuery:
def refresh(
self, *, repaint: bool = True, layout: bool = False
) -> DOMQuery[QueryType]:
"""Refresh matched nodes.
Args:

View File

@@ -327,6 +327,22 @@ class ScalarOffset(NamedTuple):
"""Get a null scalar offset (0, 0)."""
return NULL_SCALAR
@classmethod
def from_offset(cls, offset: tuple[int, int]) -> ScalarOffset:
"""Create a Scalar offset from a tuple of integers.
Args:
offset (tuple[int, int]): Offset in cells.
Returns:
ScalarOffset: New offset.
"""
x, y = offset
return cls(
Scalar(x, Unit.CELLS, Unit.WIDTH),
Scalar(y, Unit.CELLS, Unit.HEIGHT),
)
def __bool__(self) -> bool:
x, y = self
return bool(x.value or y.value)
@@ -336,6 +352,15 @@ class ScalarOffset(NamedTuple):
yield None, str(self.y)
def resolve(self, size: Size, viewport: Size) -> Offset:
"""Resolve the offset in to cells.
Args:
size (Size): Size of container.
viewport (Size): Size of viewport.
Returns:
Offset: Offset in cells.
"""
x, y = self
return Offset(
round(x.resolve_dimension(size, viewport)),
@@ -354,8 +379,7 @@ def percentage_string_to_float(string: str) -> float:
"""
string = string.strip()
if string.endswith("%"):
percentage = string[:-1]
float_percentage = clamp(float(percentage) / 100, 0, 1)
float_percentage = clamp(float(string[:-1]) / 100.0, 0.0, 1.0)
else:
float_percentage = float(string)
return float_percentage

View File

@@ -160,7 +160,7 @@ class ColorSystem:
("primary-background", primary),
("secondary-background", secondary),
("background", background),
("foregroud", foreground),
("foreground", foreground),
("panel", panel),
("boost", boost),
("surface", surface),

View File

@@ -675,7 +675,17 @@ class DOMNode(MessagePump):
return child
raise NoMatchingNodesError(f"No child found with id={id!r}")
def query(self, selector: str | None = None) -> DOMQuery:
ExpectType = TypeVar("ExpectType", bound="Widget")
@overload
def query(self, selector: str | None) -> DOMQuery:
...
@overload
def query(self, selector: type[ExpectType]) -> DOMQuery[ExpectType]:
...
def query(self, selector: str | type | None = None) -> DOMQuery:
"""Get a DOM query matching a selector.
Args:
@@ -686,9 +696,13 @@ class DOMNode(MessagePump):
"""
from .css.query import DOMQuery
return DOMQuery(self, filter=selector)
query: str | None
if isinstance(selector, str) or selector is None:
query = selector
else:
query = selector.__name__
ExpectType = TypeVar("ExpectType")
return DOMQuery(self, filter=query)
@overload
def query_one(self, selector: str) -> Widget:
@@ -723,7 +737,7 @@ class DOMNode(MessagePump):
query_selector = selector
else:
query_selector = selector.__name__
query = DOMQuery(self, filter=query_selector)
query: DOMQuery[Widget] = DOMQuery(self, filter=query_selector)
if expect_type is None:
return query.first()

View File

@@ -7,7 +7,6 @@ from rich.style import Style
from ._types import MessageTarget
from .geometry import Offset, Size
from .keys import KEY_VALUES, Keys
from .message import Message
MouseEventT = TypeVar("MouseEventT", bound="MouseEvent")
@@ -317,15 +316,45 @@ class MouseEvent(InputEvent, bubble=True):
yield "meta", self.meta, False
yield "ctrl", self.ctrl, False
@property
def offset(self) -> Offset:
"""The mouse coordinate as an offset.
Returns:
Offset: Mouse coordinate.
"""
return Offset(self.x, self.y)
@property
def screen_offset(self) -> Offset:
"""Mouse coordinate relative to the screen.
Returns:
Offset: Mouse coordinate.
"""
return Offset(self.screen_x, self.screen_y)
@property
def delta(self) -> Offset:
"""Mouse coordinate delta (change since last event).
Returns:
Offset: Mouse coordinate.
"""
return Offset(self.delta_x, self.delta_y)
@property
def style(self) -> Style:
"""The (Rich) Style under the cursor."""
return self._style or Style()
@style.setter
def style(self, style: Style) -> None:
self._style = style
def offset(self, x: int, y: int) -> MouseEvent:
def _apply_offset(self, x: int, y: int) -> MouseEvent:
return self.__class__(
self.sender,
x=self.x + x,
@@ -343,7 +372,7 @@ class MouseEvent(InputEvent, bubble=True):
@rich.repr.auto
class MouseMove(MouseEvent, bubble=True, verbose=True):
class MouseMove(MouseEvent, bubble=False, verbose=True):
"""Sent when the mouse cursor moves."""

View File

@@ -200,4 +200,9 @@ class Keys(str, Enum):
ShiftControlEnd = ControlShiftEnd
KEY_VALUES = frozenset(Keys.__members__.values())
# Unicode db contains some obscure names
# This mapping replaces them with more common terms
KEY_NAME_REPLACEMENTS = {
"solidus": "slash",
"reverse_solidus": "backslash",
}

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import ClassVar
from typing import ClassVar, TYPE_CHECKING
import rich.repr
@@ -8,6 +8,9 @@ from . import _clock
from .case import camel_to_snake
from ._types import MessageTarget as MessageTarget
if TYPE_CHECKING:
from .widget import Widget
@rich.repr.auto
class Message:
@@ -109,3 +112,11 @@ class Message:
"""
self._stop_propagation = stop
return self
async def _bubble_to(self, widget: Widget) -> None:
"""Bubble to a widget (typically the parent).
Args:
widget (Widget): Target of bubble.
"""
await widget.post_message(self)

View File

@@ -417,7 +417,7 @@ class MessagePump(metaclass=MessagePumpMeta):
# parent is sender, so we stop propagation after parent
message.stop()
if self.is_parent_active and not self._parent._closing:
await self._parent.post_message(message)
await message._bubble_to(self._parent)
def check_idle(self) -> None:
"""Prompt the message pump to call idle if the queue is empty."""

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import sys
from typing import Iterable
import rich.repr
from rich.console import RenderableType
@@ -106,6 +107,18 @@ class Screen(Widget):
"""
return self._compositor.get_widget_at(x, y)
def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
"""Get all widgets under a given coordinate.
Args:
x (int): X coordinate.
y (int): Y coordinate.
Returns:
Iterable[tuple[Widget, Region]]: Sequence of (WIDGET, REGION) tuples.
"""
return self._compositor.get_widgets_at(x, y)
def get_style_at(self, x: int, y: int) -> Style:
"""Get the style under a given coordinate.
@@ -315,7 +328,9 @@ class Screen(Widget):
event._set_forwarded()
await self.post_message(event)
else:
await widget._forward_event(event.offset(-region.x, -region.y))
await widget._forward_event(
event._apply_offset(-region.x, -region.y)
)
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
try:

View File

@@ -97,13 +97,13 @@ class ScrollView(Widget):
virtual_size (Size): New virtual size.
container_size (Size): New container size.
"""
virtual_size = self.virtual_size
if self._size != size:
if self._size != size or virtual_size != self.virtual_size:
self._size = size
self._container_size = size
virtual_size = self.virtual_size
self._container_size = size - self.gutter.totals
self._scroll_update(virtual_size)
self.scroll_to(self.scroll_x, self.scroll_y)
self.refresh()
def render(self) -> RenderableType:
"""Render the scrollable region (if `render_lines` is not implemented).

View File

@@ -28,6 +28,7 @@ from ._layout import Layout
from ._segment_tools import align_lines
from ._styles_cache import StylesCache
from ._types import Lines
from .css.scalar import ScalarOffset
from .binding import NoBinding
from .box_model import BoxModel, get_box_model
from .dom import DOMNode, NoScreen
@@ -255,6 +256,19 @@ class Widget(DOMNode):
self.allow_horizontal_scroll or self.allow_vertical_scroll
)
@property
def offset(self) -> Offset:
"""Widget offset from origin.
Returns:
Offset: Relative offset.
"""
return self.styles.offset.resolve(self.size, self.app.size)
@offset.setter
def offset(self, offset: Offset) -> None:
self.styles.offset = ScalarOffset.from_offset(offset)
def get_component_rich_style(self, name: str) -> Style:
"""Get a *Rich* style for a component.
@@ -1526,7 +1540,11 @@ class Widget(DOMNode):
virtual_size (Size): Virtual (scrollable) size.
container_size (Size): Container size (size of parent).
"""
if self._size != size or self.virtual_size != virtual_size:
if (
self._size != size
or self.virtual_size != virtual_size
or self._container_size != container_size
):
self._size = size
self.virtual_size = virtual_size
self._container_size = container_size
@@ -1543,6 +1561,7 @@ class Widget(DOMNode):
"""
self._refresh_scrollbars()
width, height = self.container_size
if self.show_vertical_scrollbar:
self.vertical_scrollbar.window_virtual_size = virtual_size.height
self.vertical_scrollbar.window_size = (

View File

@@ -43,7 +43,7 @@ class TextWidgetBase(Widget):
changed = False
if event.char is not None and event.is_printable:
changed = self._editor.insert(event.char)
elif key == "ctrl+h":
elif key == "backspace":
changed = self._editor.delete_back()
elif key == "ctrl+d":
changed = self._editor.delete_forward()
@@ -358,7 +358,7 @@ class TextInput(TextWidgetBase, can_focus=True):
)
else:
self.app.bell()
elif key == "ctrl+h":
elif key == "backspace":
if cursor_index == start and self._editor.query_cursor_left():
self._slide_window(-1)
self._update_suggestion(event)

View File

@@ -1,6 +1,10 @@
from __future__ import annotations
from typing import cast
from rich.console import RenderableType
from rich.pretty import Pretty
from rich.protocol import is_renderable
from rich.segment import Segment
from ..reactive import var
@@ -46,19 +50,26 @@ class TextLog(ScrollView, can_focus=True):
def _on_styles_updated(self) -> None:
self._line_cache.clear()
def write(self, content: RenderableType) -> None:
def write(self, content: RenderableType | object) -> None:
"""Write text or a rich renderable.
Args:
content (RenderableType): Rich renderable (or text).
"""
renderable: RenderableType
if not is_renderable(content):
renderable = Pretty(content)
else:
renderable = cast(RenderableType, content)
console = self.app.console
width = max(self.min_width, self.size.width or self.min_width)
render_options = console.options.update_width(width)
if not self.wrap:
render_options = render_options.update(overflow="ignore", no_wrap=True)
segments = self.app.console.render(content, render_options)
segments = self.app.console.render(renderable, render_options)
lines = list(Segment.split_lines(segments))
self.max_width = max(
self.max_width,

View File

@@ -101,12 +101,12 @@ def test_cant_match_escape_sequence_too_long(parser):
assert all(isinstance(event, Key) for event in events)
# When we backtrack '\x1b' is translated to '^'
assert events[0].key == "^"
assert events[0].key == "circumflex_accent"
# The rest of the characters correspond to the expected key presses
events = events[1:]
for index, character in enumerate(sequence[1:]):
assert events[index].key == character
assert events[index].char == character
@pytest.mark.parametrize(
@@ -141,9 +141,9 @@ def test_unknown_sequence_followed_by_known_sequence(parser, chunk_size):
events = list(itertools.chain.from_iterable(list(event) for event in events))
assert [event.key for event in events] == [
"^",
"[",
"?",
"circumflex_accent",
"left_square_bracket",
"question_mark",
"end",
]