Skip to content

Commit

Permalink
Merge branch 'main' into widget-importing
Browse files Browse the repository at this point in the history
  • Loading branch information
davep authored Jan 23, 2023
2 parents 289135a + 9e35f7b commit 3212fbc
Show file tree
Hide file tree
Showing 15 changed files with 467 additions and 286 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- Breaking change: `TreeNode` can no longer be imported from `textual.widgets`; it is now available via `from textual.widgets.tree import TreeNode`. https://github.com/Textualize/textual/pull/1637

### Fixed

- Fixed stuck screen https://github.com/Textualize/textual/issues/1632
- Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406

## [0.10.1] - 2023-01-20

### Added
Expand Down
325 changes: 179 additions & 146 deletions poetry.lock

Large diffs are not rendered by default.

50 changes: 47 additions & 3 deletions src/textual/_callback.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
from __future__ import annotations

import asyncio
from functools import lru_cache

from inspect import signature, isawaitable
from typing import Any, Callable
from typing import Any, Callable, TYPE_CHECKING

from . import active_app

if TYPE_CHECKING:
from .app import App

# Maximum seconds before warning about a slow callback
INVOKE_TIMEOUT_WARNING = 3


@lru_cache(maxsize=2048)
Expand All @@ -12,7 +20,7 @@ def count_parameters(func: Callable) -> int:
return len(signature(func).parameters)


async def invoke(callback: Callable, *params: object) -> Any:
async def _invoke(callback: Callable, *params: object) -> Any:
"""Invoke a callback with an arbitrary number of parameters.
Args:
Expand All @@ -27,3 +35,39 @@ async def invoke(callback: Callable, *params: object) -> Any:
if isawaitable(result):
result = await result
return result


async def invoke(callback: Callable, *params: object) -> Any:
"""Invoke a callback with an arbitrary number of parameters.
Args:
callback: The callable to be invoked.
Returns:
The return value of the invoked callable.
"""

app: App | None
try:
app = active_app.get()
except LookupError:
# May occur if this method is called outside of an app context (i.e. in a unit test)
app = None

if app is not None and "debug" in app.features:
# In debug mode we will warn about callbacks that may be stuck
def log_slow() -> None:
"""Log a message regarding a slow callback."""
app.log.warning(
f"Callback {callback} is still pending after {INVOKE_TIMEOUT_WARNING} seconds"
)

call_later_handle = asyncio.get_running_loop().call_later(
INVOKE_TIMEOUT_WARNING, log_slow
)
try:
return await _invoke(callback, *params)
finally:
call_later_handle.cancel()
else:
return await _invoke(callback, *params)
8 changes: 6 additions & 2 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1191,9 +1191,13 @@ def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]:
_screen = self.get_screen(screen)
if not _screen.is_running:
widgets = self._register(self, _screen)
return (_screen, AwaitMount(_screen, widgets))
await_mount = AwaitMount(_screen, widgets)
self.call_next(await_mount)
return (_screen, await_mount)
else:
return (_screen, AwaitMount(_screen, []))
await_mount = AwaitMount(_screen, [])
self.call_next(await_mount)
return (_screen, await_mount)

def _replace_screen(self, screen: Screen) -> Screen:
"""Handle the replaced screen.
Expand Down
8 changes: 7 additions & 1 deletion src/textual/cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from __future__ import annotations

import sys

try:
import click
except ImportError:
print("Please install 'textual[dev]' to use the 'textual' command")
sys.exit(1)

import click
from importlib_metadata import version

from textual.pilot import Pilot
Expand Down
5 changes: 4 additions & 1 deletion src/textual/css/_style_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ def __set__(


class ScalarListProperty:
def __init__(self, percent_unit: Unit) -> None:
self.percent_unit = percent_unit

def __set_name__(self, owner: Styles, name: str) -> None:
self.name = name

Expand Down Expand Up @@ -229,7 +232,7 @@ def __set__(
scalars.append(Scalar.from_number(parse_value))
else:
scalars.append(
Scalar.parse(parse_value)
Scalar.parse(parse_value, self.percent_unit)
if isinstance(parse_value, str)
else parse_value
)
Expand Down
8 changes: 2 additions & 6 deletions src/textual/css/_styles_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -865,16 +865,12 @@ def process_scrollbar_size_horizontal(self, name: str, tokens: list[Token]) -> N

def _process_grid_rows_or_columns(self, name: str, tokens: list[Token]) -> None:
scalars: list[Scalar] = []
percent_unit = Unit.WIDTH if name == "grid-columns" else Unit.HEIGHT
for token in tokens:
if token.name == "number":
scalars.append(Scalar.from_number(float(token.value)))
elif token.name == "scalar":
scalars.append(
Scalar.parse(
token.value,
percent_unit=Unit.WIDTH if name == "rows" else Unit.HEIGHT,
)
)
scalars.append(Scalar.parse(token.value, percent_unit=percent_unit))
else:
self.error(
name,
Expand Down
4 changes: 2 additions & 2 deletions src/textual/css/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,8 @@ class StylesBase(ABC):
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
content_align = AlignProperty()

grid_rows = ScalarListProperty()
grid_columns = ScalarListProperty()
grid_rows = ScalarListProperty(percent_unit=Unit.HEIGHT)
grid_columns = ScalarListProperty(percent_unit=Unit.WIDTH)

grid_size_columns = IntegerProperty(default=1, layout=True)
grid_size_rows = IntegerProperty(default=0, layout=True)
Expand Down
82 changes: 57 additions & 25 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def __new__(
class MessagePump(metaclass=MessagePumpMeta):
def __init__(self, parent: MessagePump | None = None) -> None:
self._message_queue: Queue[Message | None] = Queue()
self._active_message: Message | None = None
self._parent = parent
self._running: bool = False
self._closing: bool = False
Expand All @@ -75,6 +76,7 @@ def __init__(self, parent: MessagePump | None = None) -> None:
self._last_idle: float = time()
self._max_idle: float | None = None
self._mounted_event = asyncio.Event()
self._next_callbacks: list[CallbackType] = []

@property
def task(self) -> Task:
Expand Down Expand Up @@ -271,10 +273,22 @@ def call_later(self, callback: Callable, *args, **kwargs) -> None:
Args:
callback: Callable to call next.
*args: Positional arguments to pass to the callable.
**kwargs: Keyword arguments to pass to the callable.
"""
message = events.Callback(self, callback=partial(callback, *args, **kwargs))
self.post_message_no_wait(message)

def call_next(self, callback: Callable, *args, **kwargs) -> None:
"""Schedule a callback to run immediately after processing the current message.
Args:
callback: Callable to run after current event.
*args: Positional arguments to pass to the callable.
**kwargs: Keyword arguments to pass to the callable.
"""
self._next_callbacks.append(partial(callback, *args, **kwargs))

def _on_invoke_later(self, message: messages.InvokeLater) -> None:
# Forward InvokeLater message to the Screen
self.app.screen._invoke_later(message.callback)
Expand Down Expand Up @@ -367,35 +381,52 @@ async def _process_messages_loop(self) -> None:
except MessagePumpClosed:
break

self._active_message = message

try:
await self._dispatch_message(message)
except CancelledError:
raise
try:
await self._dispatch_message(message)
except CancelledError:
raise
except Exception as error:
self._mounted_event.set()
self.app._handle_exception(error)
break
finally:

self._message_queue.task_done()

current_time = time()

# Insert idle events
if self._message_queue.empty() or (
self._max_idle is not None
and current_time - self._last_idle > self._max_idle
):
self._last_idle = current_time
if not self._closed:
event = events.Idle(self)
for _cls, method in self._get_dispatch_methods(
"on_idle", event
):
try:
await invoke(method, event)
except Exception as error:
self.app._handle_exception(error)
break
finally:
self._active_message = None

async def _flush_next_callbacks(self) -> None:
"""Invoke pending callbacks in next callbacks queue."""
callbacks = self._next_callbacks.copy()
self._next_callbacks.clear()
for callback in callbacks:
try:
await invoke(callback)
except Exception as error:
self._mounted_event.set()
self.app._handle_exception(error)
break
finally:

self._message_queue.task_done()
current_time = time()

# Insert idle events
if self._message_queue.empty() or (
self._max_idle is not None
and current_time - self._last_idle > self._max_idle
):
self._last_idle = current_time
if not self._closed:
event = events.Idle(self)
for _cls, method in self._get_dispatch_methods(
"on_idle", event
):
try:
await invoke(method, event)
except Exception as error:
self.app._handle_exception(error)
break

async def _dispatch_message(self, message: Message) -> None:
"""Dispatch a message received from the message queue.
Expand All @@ -412,6 +443,7 @@ async def _dispatch_message(self, message: Message) -> None:
await self.on_event(message)
else:
await self._on_message(message)
await self._flush_next_callbacks()

def _get_dispatch_methods(
self, method_name: str, message: Message
Expand Down
27 changes: 14 additions & 13 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,19 +367,20 @@ async def _on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()

async with self.app._dom_lock:
if self.is_current:
if self._layout_required:
self._refresh_layout()
self._layout_required = False
self._dirty_widgets.clear()
if self._repaint_required:
self._dirty_widgets.clear()
self._dirty_widgets.add(self)
self._repaint_required = False

if self._dirty_widgets:
self.update_timer.resume()
if self.is_current:
async with self.app._dom_lock:
if self.is_current:
if self._layout_required:
self._refresh_layout()
self._layout_required = False
self._dirty_widgets.clear()
if self._repaint_required:
self._dirty_widgets.clear()
self._dirty_widgets.add(self)
self._repaint_required = False

if self._dirty_widgets:
self.update_timer.resume()

# The Screen is idle - a good opportunity to invoke the scheduled callbacks
await self._invoke_and_clear_callbacks()
Expand Down
24 changes: 19 additions & 5 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None:
self._parent = parent
self._widgets = widgets

async def __call__(self) -> None:
"""Allows awaiting via a call operation."""
await self

def __await__(self) -> Generator[None, None, None]:
async def await_mount() -> None:
if self._widgets:
Expand Down Expand Up @@ -557,8 +561,12 @@ def mount(
Args:
*widgets: The widget(s) to mount.
before: Optional location to mount before.
after: Optional location to mount after.
before: Optional location to mount before. An `int` is the index
of the child to mount before, a `str` is a `query_one` query to
find the widget to mount before.
after: Optional location to mount after. An `int` is the index
of the child to mount after, a `str` is a `query_one` query to
find the widget to mount after.
Returns:
An awaitable object that waits for widgets to be mounted.
Expand Down Expand Up @@ -606,7 +614,9 @@ def mount(
parent, *widgets, before=insert_before, after=insert_after
)

return AwaitMount(self, mounted)
await_mount = AwaitMount(self, mounted)
self.call_next(await_mount)
return await_mount

def move_child(
self,
Expand All @@ -618,8 +628,12 @@ def move_child(
Args:
child: The child widget to move.
before: Optional location to move before.
after: Optional location to move after.
before: Optional location to move before. An `int` is the index
of the child to move before, a `str` is a `query_one` query to
find the widget to move before.
after: Optional location to move after. An `int` is the index
of the child to move after, a `str` is a `query_one` query to
find the widget to move after.
Raises:
WidgetError: If there is a problem with the child or target.
Expand Down
1 change: 1 addition & 0 deletions src/textual/widgets/_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def __init__(self) -> None:
async def watch_highlight_key(self, value) -> None:
"""If highlight key changes we need to regenerate the text."""
self._key_text = None
self.refresh()

def on_mount(self) -> None:
watch(self.screen, "focused", self._focus_changed)
Expand Down
Loading

0 comments on commit 3212fbc

Please sign in to comment.