Skip to content

Commit

Permalink
Merge branch 'main' of github.com:Textualize/textual into update-test…
Browse files Browse the repository at this point in the history
…s-to-support-xdist
  • Loading branch information
darrenburns committed Jul 25, 2024
2 parents 1a07466 + 988c4b3 commit 932152c
Show file tree
Hide file tree
Showing 23 changed files with 547 additions and 125 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed

- Fixed issues in Kitty terminal after exiting app https://github.com/Textualize/textual/issues/4779
- Fixed exception when removing Selects https://github.com/Textualize/textual/pull/4786
- Fixed issue with non-clickable Footer keys https://github.com/Textualize/textual/pull/4798
- Fixed issue with recompose not working from Mount handler https://github.com/Textualize/textual/pull/4802

### Changed

- Calling `Screen.dismiss` with no arguments will invoke the screen callback with `None` (previously the callback wasn't invoke at all). https://github.com/Textualize/textual/pull/4795

## [0.73.0] - 2024-07-18

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/guide/screens/modal03.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def compose(self) -> ComposeResult:
def action_request_quit(self) -> None:
"""Action to display the quit dialog."""

def check_quit(quit: bool) -> None:
def check_quit(quit: bool | None) -> None:
"""Called when QuitScreen is dismissed."""
if quit:
self.exit()
Expand Down
26 changes: 26 additions & 0 deletions src/textual/_debug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Functions related to debugging.
"""

from __future__ import annotations

from . import constants


def get_caller_file_and_line() -> str | None:
"""Get the caller filename and line, if in debug mode, otherwise return `None`:
Returns:
Path and file if `constants.DEBUG==True`
"""

if not constants.DEBUG:
return None
import inspect

try:
current_frame = inspect.currentframe()
caller_frame = inspect.getframeinfo(current_frame.f_back.f_back)
return f"{caller_frame.filename}:{caller_frame.lineno}"
except Exception:
return None
68 changes: 68 additions & 0 deletions src/textual/_dispatch_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

from typing import Callable

from . import events
from ._callback import invoke
from .dom import DOMNode
from .errors import DuplicateKeyHandlers
from .message_pump import MessagePump


async def dispatch_key(node: DOMNode, event: events.Key) -> bool:
"""Dispatch a key event to method.
This function will call the method named 'key_<event.key>' on a node if it exists.
Some keys have aliases. The first alias found will be invoked if it exists.
If multiple handlers exist that match the key, an exception is raised.
Args:
event: A key event.
Returns:
True if key was handled, otherwise False.
Raises:
DuplicateKeyHandlers: When there's more than 1 handler that could handle this key.
"""

def get_key_handler(pump: MessagePump, key: str) -> Callable | None:
"""Look for the public and private handler methods by name on self."""
return getattr(pump, f"key_{key}", None) or getattr(pump, f"_key_{key}", None)

handled = False
invoked_method = None
key_name = event.name
if not key_name:
return False

def _raise_duplicate_key_handlers_error(
key_name: str, first_handler: str, second_handler: str
) -> None:
"""Raise exception for case where user presses a key and there are multiple candidate key handler methods for it."""
raise DuplicateKeyHandlers(
f"Multiple handlers for key press {key_name!r}.\n"
f"We found both {first_handler!r} and {second_handler!r}, "
f"and didn't know which to call.\n"
f"Consider combining them into a single handler.",
)

try:
screen = node.screen
except Exception:
screen = None
for key_method_name in event.name_aliases:
if (key_method := get_key_handler(node, key_method_name)) is not None:
if invoked_method:
_raise_duplicate_key_handlers_error(
key_name, invoked_method.__name__, key_method.__name__
)
# If key handlers return False, then they are not considered handled
# This allows key handlers to do some conditional logic

if screen is not None and not screen.is_active:
break
handled = (await invoke(key_method, event)) is not False
invoked_method = key_method

return handled
9 changes: 5 additions & 4 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
from ._compositor import CompositorUpdate
from ._context import active_app, active_message_pump
from ._context import message_hook as message_hook_context_var
from ._dispatch_key import dispatch_key
from ._event_broker import NoHandler, extract_handler_actions
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
from ._types import AnimationLevel
Expand Down Expand Up @@ -800,7 +801,7 @@ def is_inline(self) -> bool:
return False if self._driver is None else self._driver.is_inline

@property
def screen_stack(self) -> Sequence[Screen[Any]]:
def screen_stack(self) -> list[Screen[Any]]:
"""A snapshot of the current screen stack.
Returns:
Expand Down Expand Up @@ -3028,7 +3029,7 @@ def simulate_key(self, key: str) -> None:
Args:
key: Key to simulate. May also be the name of a key, e.g. "space".
"""
self.call_later(self._check_bindings, key)
self.post_message(events.Key(key, None))

async def _check_bindings(self, key: str, priority: bool = False) -> bool:
"""Handle a key press.
Expand Down Expand Up @@ -3282,7 +3283,7 @@ async def _on_layout(self, message: messages.Layout) -> None:

async def _on_key(self, event: events.Key) -> None:
if not (await self._check_bindings(event.key)):
await self.dispatch_key(event)
await dispatch_key(self, event)

async def _on_resize(self, event: events.Resize) -> None:
event.stop()
Expand Down Expand Up @@ -3598,7 +3599,7 @@ def clear_notifications(self) -> None:
def action_command_palette(self) -> None:
"""Show the Textual command palette."""
if self.use_command_palette and not CommandPalette.is_open(self):
self.push_screen(CommandPalette(), callback=self.call_next)
self.push_screen(CommandPalette())

def _suspend_signal(self) -> None:
"""Signal that the application is being suspended."""
Expand Down
32 changes: 30 additions & 2 deletions src/textual/await_complete.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,50 @@
from __future__ import annotations

from asyncio import Future, gather
from typing import Any, Awaitable, Generator
from typing import TYPE_CHECKING, Any, Awaitable, Generator

import rich.repr
from typing_extensions import Self

from ._debug import get_caller_file_and_line
from .message_pump import MessagePump

if TYPE_CHECKING:
from .types import CallbackType


@rich.repr.auto(angular=True)
class AwaitComplete:
"""An 'optionally-awaitable' object which runs one or more coroutines (or other awaitables) concurrently."""

def __init__(self, *awaitables: Awaitable) -> None:
def __init__(
self, *awaitables: Awaitable, pre_await: CallbackType | None = None
) -> None:
"""Create an AwaitComplete.
Args:
awaitables: One or more awaitables to run concurrently.
"""
self._awaitables = awaitables
self._future: Future[Any] = gather(*awaitables)
self._pre_await: CallbackType | None = pre_await
self._caller = get_caller_file_and_line()

def __rich_repr__(self) -> rich.repr.Result:
yield self._awaitables
yield "pre_await", self._pre_await, None
yield "caller", self._caller, None

def set_pre_await_callback(self, pre_await: CallbackType | None) -> None:
"""Set a callback to run prior to awaiting.
This is used by Textual, mainly to check for possible deadlocks.
You are unlikely to need to call this method in an app.
Args:
pre_await: A callback.
"""
self._pre_await = pre_await

def call_next(self, node: MessagePump) -> Self:
"""Await after the next message.
Expand All @@ -34,6 +59,9 @@ async def __call__(self) -> Any:
return await self

def __await__(self) -> Generator[Any, None, Any]:
_rich_traceback_omit = True
if self._pre_await is not None:
self._pre_await()
return self._future.__await__()

@property
Expand Down
10 changes: 10 additions & 0 deletions src/textual/await_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
from asyncio import Task, gather
from typing import Generator

import rich.repr

from ._callback import invoke
from ._debug import get_caller_file_and_line
from ._types import CallbackType


@rich.repr.auto
class AwaitRemove:
"""An awaitable that waits for nodes to be removed."""

Expand All @@ -20,6 +24,12 @@ def __init__(
) -> None:
self._tasks = tasks
self._post_remove = post_remove
self._caller = get_caller_file_and_line()

def __rich_repr__(self) -> rich.repr.Result:
yield "tasks", self._tasks
yield "post_remove", self._post_remove
yield "caller", self._caller, None

async def __call__(self) -> None:
await self
Expand Down
7 changes: 4 additions & 3 deletions src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from .reactive import var
from .screen import Screen, SystemModalScreen
from .timer import Timer
from .types import CallbackType, IgnoreReturnCallbackType
from .types import IgnoreReturnCallbackType
from .widget import Widget
from .widgets import Button, Input, LoadingIndicator, OptionList, Static
from .widgets.option_list import Option
Expand Down Expand Up @@ -419,7 +419,7 @@ class CommandInput(Input):
"""


class CommandPalette(SystemModalScreen[CallbackType]):
class CommandPalette(SystemModalScreen):
"""The Textual command palette."""

AUTO_FOCUS = "CommandInput"
Expand Down Expand Up @@ -1079,7 +1079,8 @@ def _select_or_command(
# decide what to do with it (hopefully it'll run it).
self._cancel_gather_commands()
self.app.post_message(CommandPalette.Closed(option_selected=True))
self.dismiss(self._selected_command.command)
self.dismiss()
self.call_later(self._selected_command.command)

@on(OptionList.OptionHighlighted)
def _stop_event_leak(self, event: OptionList.OptionHighlighted) -> None:
Expand Down
62 changes: 1 addition & 61 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
from ._on import OnNoWidget
from ._time import time
from .css.match import match
from .errors import DuplicateKeyHandlers
from .events import Event
from .message import Message
from .reactive import Reactive, TooManyComputesError
Expand Down Expand Up @@ -447,6 +446,7 @@ def call_next(self, callback: Callback, *args: Any, **kwargs: Any) -> None:
*args: Positional arguments to pass to the callable.
**kwargs: Keyword arguments to pass to the callable.
"""
assert callback is not None, "Callback must not be None"
callback_message = events.Callback(callback=partial(callback, *args, **kwargs))
callback_message._prevent.update(self._get_prevented_messages())
self._next_callbacks.append(callback_message)
Expand Down Expand Up @@ -802,54 +802,6 @@ async def on_callback(self, event: events.Callback) -> None:
return
await invoke(event.callback)

# TODO: Does dispatch_key belong on message pump?
async def dispatch_key(self, event: events.Key) -> bool:
"""Dispatch a key event to method.
This method will call the method named 'key_<event.key>' if it exists.
Some keys have aliases. The first alias found will be invoked if it exists.
If multiple handlers exist that match the key, an exception is raised.
Args:
event: A key event.
Returns:
True if key was handled, otherwise False.
Raises:
DuplicateKeyHandlers: When there's more than 1 handler that could handle this key.
"""

def get_key_handler(pump: MessagePump, key: str) -> Callable | None:
"""Look for the public and private handler methods by name on self."""
public_handler_name = f"key_{key}"
public_handler = getattr(pump, public_handler_name, None)

private_handler_name = f"_key_{key}"
private_handler = getattr(pump, private_handler_name, None)

return public_handler or private_handler

handled = False
invoked_method = None
key_name = event.name
if not key_name:
return False

for key_method_name in event.name_aliases:
key_method = get_key_handler(self, key_method_name)
if key_method is not None:
if invoked_method:
_raise_duplicate_key_handlers_error(
key_name, invoked_method.__name__, key_method.__name__
)
# If key handlers return False, then they are not considered handled
# This allows key handlers to do some conditional logic
handled = (await invoke(key_method, event)) is not False
invoked_method = key_method

return handled

async def on_timer(self, event: events.Timer) -> None:
if not self.app._running:
return
Expand All @@ -862,15 +814,3 @@ async def on_timer(self, event: events.Timer) -> None:
raise CallbackError(
f"unable to run callback {event.callback!r}; {error}"
)


def _raise_duplicate_key_handlers_error(
key_name: str, first_handler: str, second_handler: str
) -> None:
"""Raise exception for case where user presses a key and there are multiple candidate key handler methods for it."""
raise DuplicateKeyHandlers(
f"Multiple handlers for key press {key_name!r}.\n"
f"We found both {first_handler!r} and {second_handler!r}, "
f"and didn't know which to call.\n"
f"Consider combining them into a single handler.",
)
17 changes: 10 additions & 7 deletions src/textual/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,18 @@ def __init__(
self._recompose = recompose
self._bindings = bindings
self._owner: Type[MessageTarget] | None = None
self.name: str

def __rich_repr__(self) -> rich.repr.Result:
yield self._default
yield "layout", self._layout
yield "repaint", self._repaint
yield "init", self._init
yield "always_update", self._always_update
yield "compute", self._run_compute
yield "recompose", self._recompose
yield None, self._default
yield "layout", self._layout, False
yield "repaint", self._repaint, True
yield "init", self._init, False
yield "always_update", self._always_update, False
yield "compute", self._run_compute, True
yield "recompose", self._recompose, False
yield "bindings", self._bindings, False
yield "name", getattr(self, "name", None), None

@property
def owner(self) -> Type[MessageTarget]:
Expand Down
Loading

0 comments on commit 932152c

Please sign in to comment.