input docs an exampels

This commit is contained in:
Will McGugan
2022-09-24 17:04:00 +01:00
parent a41270f7d7
commit 53d8e02d0d
23 changed files with 322 additions and 45 deletions

View File

@@ -7,6 +7,7 @@ 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. |

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

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,12 @@
Screen {
layout: horizontal;
}
KeyLogger {
width: 1fr;
border: blank;
}
KeyLogger:focus {
border: wide $accent;
}

View File

@@ -0,0 +1,23 @@
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 = "input02.css"
def compose(self) -> ComposeResult:
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,31 @@
from textual.app import App, ComposeResult
from textual import events
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()

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

@@ -0,0 +1,26 @@
# 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
## Key events
The most fundamental way to receive input in via [Key](./events/key) events. Let's take a closer look at key events with an app that will display 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 which

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"

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

@@ -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
@@ -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,16 @@ 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
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

@@ -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,14 @@ class ScalarOffset(NamedTuple):
"""Get a null scalar offset (0, 0)."""
return NULL_SCALAR
@classmethod
def from_offset(cls, offset: tuple[int, int]) -> ScalarOffset:
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)

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,6 +316,14 @@ class MouseEvent(InputEvent, bubble=True):
yield "meta", self.meta, False
yield "ctrl", self.ctrl, False
@property
def offset(self) -> Offset:
return Offset(self.x, self.y)
@property
def screen_offset(self) -> Offset:
return Offset(self.screen_x, self.screen_y)
@property
def style(self) -> Style:
return self._style or Style()
@@ -325,7 +332,7 @@ class MouseEvent(InputEvent, bubble=True):
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 +350,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

@@ -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,14 @@ class Widget(DOMNode):
self.allow_horizontal_scroll or self.allow_vertical_scroll
)
@property
def offset(self) -> 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 +1535,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 +1556,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

@@ -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,