mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -8,3 +8,7 @@ The `Blur` event is sent to a widget when it loses focus.
|
||||
## Attributes
|
||||
|
||||
_No other attributes_
|
||||
|
||||
## Code
|
||||
|
||||
::: textual.events.Blur
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,3 +8,7 @@ The `ScreenResume` event is sent to a **Screen** when it becomes current.
|
||||
## Attributes
|
||||
|
||||
_No other attributes_
|
||||
|
||||
## Code
|
||||
|
||||
::: textual.events.ScreenResume
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,3 +8,7 @@ The `Show` event is sent to a widget when it becomes visible.
|
||||
## Attributes
|
||||
|
||||
_No additional attributes_
|
||||
|
||||
## Code
|
||||
|
||||
::: textual.events.Show
|
||||
|
||||
7
docs/examples/guide/input/binding01.css
Normal file
7
docs/examples/guide/input/binding01.css
Normal file
@@ -0,0 +1,7 @@
|
||||
Bar {
|
||||
height: 5;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
margin: 1 2;
|
||||
color: $text;
|
||||
}
|
||||
31
docs/examples/guide/input/binding01.py
Normal file
31
docs/examples/guide/input/binding01.py
Normal 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()
|
||||
21
docs/examples/guide/input/key01.py
Normal file
21
docs/examples/guide/input/key01.py
Normal 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()
|
||||
21
docs/examples/guide/input/key02.py
Normal file
21
docs/examples/guide/input/key02.py
Normal 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()
|
||||
17
docs/examples/guide/input/key03.css
Normal file
17
docs/examples/guide/input/key03.css
Normal 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;
|
||||
}
|
||||
25
docs/examples/guide/input/key03.py
Normal file
25
docs/examples/guide/input/key03.py
Normal 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()
|
||||
24
docs/examples/guide/input/mouse01.css
Normal file
24
docs/examples/guide/input/mouse01.css
Normal 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;
|
||||
}
|
||||
30
docs/examples/guide/input/mouse01.py
Normal file
30
docs/examples/guide/input/mouse01.py
Normal 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()
|
||||
@@ -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
202
docs/guide/input.md
Normal 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.
|
||||
|
||||
16
docs/images/input/coords.excalidraw.svg
Normal file
16
docs/images/input/coords.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 21 KiB |
1
docs/reference/binding.md
Normal file
1
docs/reference/binding.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.binding.Binding
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -160,7 +160,7 @@ class ColorSystem:
|
||||
("primary-background", primary),
|
||||
("secondary-background", secondary),
|
||||
("background", background),
|
||||
("foregroud", foreground),
|
||||
("foreground", foreground),
|
||||
("panel", panel),
|
||||
("boost", boost),
|
||||
("surface", surface),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user