diff --git a/docs/events/key.md b/docs/events/key.md index 516cdafa6..95fef1168 100644 --- a/docs/events/key.md +++ b/docs/events/key.md @@ -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. | diff --git a/docs/events/mouse_move.md b/docs/events/mouse_move.md index b8d4774ac..ac69a4dc9 100644 --- a/docs/events/mouse_move.md +++ b/docs/events/mouse_move.md @@ -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 diff --git a/docs/examples/guide/input/key01.py b/docs/examples/guide/input/key01.py new file mode 100644 index 000000000..c04a7cd26 --- /dev/null +++ b/docs/examples/guide/input/key01.py @@ -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() diff --git a/docs/examples/guide/input/key02.css b/docs/examples/guide/input/key02.css new file mode 100644 index 000000000..d16e8f914 --- /dev/null +++ b/docs/examples/guide/input/key02.css @@ -0,0 +1,12 @@ +Screen { + layout: horizontal; +} + +KeyLogger { + width: 1fr; + border: blank; +} + +KeyLogger:focus { + border: wide $accent; +} diff --git a/docs/examples/guide/input/key02.py b/docs/examples/guide/input/key02.py new file mode 100644 index 000000000..9ea1a69d8 --- /dev/null +++ b/docs/examples/guide/input/key02.py @@ -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() diff --git a/docs/examples/guide/input/mouse01.css b/docs/examples/guide/input/mouse01.css new file mode 100644 index 000000000..95685d023 --- /dev/null +++ b/docs/examples/guide/input/mouse01.css @@ -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; +} diff --git a/docs/examples/guide/input/mouse01.py b/docs/examples/guide/input/mouse01.py new file mode 100644 index 000000000..4c9fb9543 --- /dev/null +++ b/docs/examples/guide/input/mouse01.py @@ -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() diff --git a/docs/guide/input.md b/docs/guide/input.md new file mode 100644 index 000000000..796e15ab8 --- /dev/null +++ b/docs/guide/input.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 92829cf76..f81573416 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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" diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 9df02dd92..d32c4a01f 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -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, diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 074154056..74023b89c 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -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() diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index f14899be2..ef5ec1fa9 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -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) diff --git a/src/textual/app.py b/src/textual/app.py index ba33a57e4..48abc106d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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") diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 202a0767a..1170228e3 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -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: diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 5edaec995..91f4d2998 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -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) diff --git a/src/textual/dom.py b/src/textual/dom.py index 0df51234f..ffbc8f587 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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() diff --git a/src/textual/events.py b/src/textual/events.py index a110fd142..0f78fb644 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -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.""" diff --git a/src/textual/message.py b/src/textual/message.py index 237874a1a..c808aed80 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -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) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c53a68373..2eb201317 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index 355769a49..8db9b91d7 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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: diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index dc0842ce1..64373d72d 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -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). diff --git a/src/textual/widget.py b/src/textual/widget.py index 0f89fab12..236e03295 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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 = ( diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index af0ecb827..090bd965c 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -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,