diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab2f483ff5..11d7609502 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
+- Fixed `Input.cursor_blink` reactive not changing blink state after `Input` was mounted https://github.com/Textualize/textual/pull/3498
+- Fixed `Tabs.active` attribute value not being re-assigned after removing a tab or clearing https://github.com/Textualize/textual/pull/3498
+- Fixed `DirectoryTree` race-condition crash when changing path https://github.com/Textualize/textual/pull/3498
- Fixed issue with `LRUCache.discard` https://github.com/Textualize/textual/issues/3537
- Fixed `DataTable` not scrolling to rows that were just added https://github.com/Textualize/textual/pull/3552
- Fixed cache bug with `DataTable.update_cell` https://github.com/Textualize/textual/pull/3551
@@ -19,6 +22,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed
+- Breaking change: `Button.ACTIVE_EFFECT_DURATION` classvar converted to `Button.active_effect_duration` attribute https://github.com/Textualize/textual/pull/3498
+- Breaking change: `Input.blink_timer` made private (renamed to `Input._blink_timer`) https://github.com/Textualize/textual/pull/3498
+- Breaking change: `Input.cursor_blink` reactive updated to not run on mount (now `init=False`) https://github.com/Textualize/textual/pull/3498
+- Breaking change: `AwaitTabbedContent` class removed https://github.com/Textualize/textual/pull/3498
+- Breaking change: `Tabs.remove_tab` now returns an `AwaitComplete` instead of an `AwaitRemove` https://github.com/Textualize/textual/pull/3498
+- Breaking change: `Tabs.clear` now returns an `AwaitComplete` instead of an `AwaitRemove` https://github.com/Textualize/textual/pull/3498
+- `TabbedContent.add_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498
+- `TabbedContent.remove_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498
+- `TabbedContent.clear_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498
+- `Tabs.add_tab` now returns an `AwaitComplete` instead of an `AwaitMount` https://github.com/Textualize/textual/pull/3498
+- `DirectoryTree.reload` now returns an `AwaitComplete`, which may be awaited to ensure the node has finished being processed by the internal queue https://github.com/Textualize/textual/pull/3498
+- `Tabs.remove_tab` now returns an `AwaitComplete`, which may be awaited to ensure the tab is unmounted and internal state is updated https://github.com/Textualize/textual/pull/3498
+- `App.switch_mode` now returns an `AwaitMount`, which may be awaited to ensure the screen is mounted https://github.com/Textualize/textual/pull/3498
- Buttons will now display multiple lines, and have auto height https://github.com/Textualize/textual/pull/3539
- DataTable now has a max-height of 100vh rather than 100%, which doesn't work with auto
- Breaking change: empty rules now result in an error https://github.com/Textualize/textual/pull/3566
@@ -29,9 +45,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `initial` to all css rules, which restores default (i.e. value from DEFAULT_CSS) https://github.com/Textualize/textual/pull/3566
- Added HorizontalPad to pad.py https://github.com/Textualize/textual/pull/3571
+### Added
+
+- Added `AwaitComplete` class, to be used for optionally awaitable return values https://github.com/Textualize/textual/pull/3498
## [0.40.0] - 2023-10-11
+### Added
+
- Added `loading` reactive property to widgets https://github.com/Textualize/textual/pull/3509
## [0.39.0] - 2023-10-10
diff --git a/src/textual/_animator.py b/src/textual/_animator.py
index 54a3cf6cf3..3561b1ba58 100644
--- a/src/textual/_animator.py
+++ b/src/textual/_animator.py
@@ -349,6 +349,7 @@ def _animate(
duration is None and speed is not None
), "An Animation should have a duration OR a speed"
+ # If an animation is already scheduled for this attribute, unschedule it.
animation_key = (id(obj), attribute)
try:
del self._scheduled[animation_key]
@@ -359,9 +360,7 @@ def _animate(
final_value = value
start_time = self._get_time()
-
easing_function = EASING[easing] if isinstance(easing, str) else easing
-
animation: Animation | None = None
if hasattr(obj, "__textual_animation__"):
diff --git a/src/textual/app.py b/src/textual/app.py
index 0da2209a09..c09fa0365a 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -1597,28 +1597,40 @@ def mount_all(
"""
return self.mount(*widgets, before=before, after=after)
- def _init_mode(self, mode: str) -> None:
+ def _init_mode(self, mode: str) -> AwaitMount:
"""Do internal initialisation of a new screen stack mode.
Args:
mode: Name of the mode.
+
+ Returns:
+ An optionally awaitable object which can be awaited until the screen
+ associated with the mode has been mounted.
"""
stack = self._screen_stacks.get(mode, [])
- if not stack:
+ if stack:
+ await_mount = AwaitMount(stack[0], [])
+ else:
_screen = self.MODES[mode]
new_screen: Screen | str = _screen() if callable(_screen) else _screen
- screen, _ = self._get_screen(new_screen)
+ screen, await_mount = self._get_screen(new_screen)
stack.append(screen)
self._load_screen_css(screen)
+
self._screen_stacks[mode] = stack
+ return await_mount
- def switch_mode(self, mode: str) -> None:
+ def switch_mode(self, mode: str) -> AwaitMount:
"""Switch to a given mode.
Args:
mode: The mode to switch to.
+ Returns:
+ An optionally awaitable object which waits for the screen associated
+ with the mode to be mounted.
+
Raises:
UnknownModeError: If trying to switch to an unknown mode.
"""
@@ -1629,13 +1641,19 @@ def switch_mode(self, mode: str) -> None:
self.screen.refresh()
if mode not in self._screen_stacks:
- self._init_mode(mode)
+ await_mount = self._init_mode(mode)
+ else:
+ await_mount = AwaitMount(self.screen, [])
+
self._current_mode = mode
self.screen._screen_resized(self.size)
self.screen.post_message(events.ScreenResume())
+
self.log.system(f"{self._current_mode!r} is the current mode")
self.log.system(f"{self.screen} is active")
+ return await_mount
+
def add_mode(
self, mode: str, base_screen: str | Screen | Callable[[], Screen]
) -> None:
diff --git a/src/textual/await_complete.py b/src/textual/await_complete.py
new file mode 100644
index 0000000000..0662a41981
--- /dev/null
+++ b/src/textual/await_complete.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from asyncio import Future, gather
+from typing import Any, Coroutine, Iterator, TypeVar
+
+import rich.repr
+
+ReturnType = TypeVar("ReturnType")
+
+
+@rich.repr.auto(angular=True)
+class AwaitComplete:
+ """An 'optionally-awaitable' object."""
+
+ def __init__(self, *coroutines: Coroutine[Any, Any, Any]) -> None:
+ """Create an AwaitComplete.
+
+ Args:
+ coroutine: One or more coroutines to execute.
+ """
+ self.coroutines = coroutines
+ self._future: Future = gather(*self.coroutines)
+
+ async def __call__(self) -> Any:
+ return await self
+
+ def __await__(self) -> Iterator[None]:
+ return self._future.__await__()
+
+ @property
+ def is_done(self) -> bool:
+ """Returns True if the task has completed."""
+ return self._future.done()
+
+ @property
+ def exception(self) -> BaseException | None:
+ """An exception if it occurred in any of the coroutines."""
+ if self._future.done():
+ return self._future.exception()
+ return None
+
+ @classmethod
+ def nothing(cls):
+ """Returns an already completed instance of AwaitComplete."""
+ instance = cls()
+ instance._future = Future()
+ instance._future.set_result(None) # Mark it as completed with no result
+ return instance
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 6960af7a2e..05f3df5247 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -8,7 +8,6 @@
from collections import Counter
from fractions import Fraction
from itertools import islice
-from operator import attrgetter
from types import TracebackType
from typing import (
TYPE_CHECKING,
diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py
index 4e75103259..e4621f563a 100644
--- a/src/textual/widgets/_button.py
+++ b/src/textual/widgets/_button.py
@@ -153,9 +153,6 @@ class Button(Static, can_focus=True):
BINDINGS = [Binding("enter", "press", "Press Button", show=False)]
- ACTIVE_EFFECT_DURATION = 0.3
- """When buttons are clicked they get the `-active` class for this duration (in seconds)"""
-
label: reactive[TextType] = reactive[TextType]("")
"""The text label that appears within the button."""
@@ -211,6 +208,9 @@ def __init__(
self.variant = self.validate_variant(variant)
+ self.active_effect_duration = 0.3
+ """Amount of time in seconds the button 'press' animation lasts."""
+
def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__()
yield "variant", self.variant, "default"
@@ -266,10 +266,11 @@ def press(self) -> Self:
def _start_active_affect(self) -> None:
"""Start a small animation to show the button was clicked."""
- self.add_class("-active")
- self.set_timer(
- self.ACTIVE_EFFECT_DURATION, partial(self.remove_class, "-active")
- )
+ if self.active_effect_duration > 0:
+ self.add_class("-active")
+ self.set_timer(
+ self.active_effect_duration, partial(self.remove_class, "-active")
+ )
def action_press(self) -> None:
"""Activate a press of the button."""
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index 1f3bb1a3f9..4b482424f9 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -1116,9 +1116,13 @@ def move_cursor(
cursor_row = row
if column is not None:
cursor_column = column
-
destination = Coordinate(cursor_row, cursor_column)
self.cursor_coordinate = destination
+
+ # Scroll the cursor after refresh to ensure the virtual height
+ # (calculated in on_idle) has settled. If we tried to scroll before
+ # the virtual size has been set, then it might fail if we added a bunch
+ # of rows then tried to immediately move the cursor.
self.call_after_refresh(self._scroll_cursor_into_view, animate=animate)
def _highlight_coordinate(self, coordinate: Coordinate) -> None:
@@ -1231,7 +1235,6 @@ def _update_column_widths(self, updated_cells: set[CellKey]) -> None:
column = self.columns.get(column_key)
if column is None:
continue
-
console = self.app.console
label_width = measure(console, column.label, 1)
content_width = column.content_width
@@ -1289,8 +1292,6 @@ def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None:
if row.auto_height:
auto_height_rows.append((row_index, row, cells_in_row))
- self._clear_caches()
-
# If there are rows that need to have their height computed, render them correctly
# so that we can cache this rendering for later.
if auto_height_rows:
@@ -1616,11 +1617,9 @@ def remove_row(self, row_key: RowKey | str) -> None:
Raises:
RowDoesNotExist: If the row key does not exist.
"""
-
if row_key not in self._row_locations:
raise RowDoesNotExist(f"Row key {row_key!r} is not valid.")
- self._new_rows.discard(row_key)
self._require_update_dimensions = True
self.check_idle()
diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py
index e85c566f49..d428939e53 100644
--- a/src/textual/widgets/_directory_tree.py
+++ b/src/textual/widgets/_directory_tree.py
@@ -5,6 +5,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Iterator
+from ..await_complete import AwaitComplete
+
if TYPE_CHECKING:
from typing_extensions import Self
@@ -152,18 +154,26 @@ def __init__(
)
self.path = path
- def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> None:
+ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete:
"""Add the given node to the load queue.
+ The return value can optionally be awaited until the queue is empty.
+
Args:
node: The node to add to the load queue.
+
+ Returns:
+ An optionally awaitable object that can be awaited until the
+ load queue has finished processing.
"""
assert node.data is not None
if not node.data.loaded:
node.data.loaded = True
self._load_queue.put_nowait(node)
- def reload(self) -> None:
+ return AwaitComplete(self._load_queue.join())
+
+ def reload(self) -> AwaitComplete:
"""Reload the `DirectoryTree` contents."""
self.reset(str(self.path), DirEntry(self.PATH(self.path)))
# Orphan the old queue...
@@ -172,7 +182,8 @@ def reload(self) -> None:
self._loader()
# We have a fresh queue, we have a fresh loader, get the fresh root
# loading up.
- self._add_to_load_queue(self.root)
+ queue_processed = self._add_to_load_queue(self.root)
+ return queue_processed
def clear_node(self, node: TreeNode[DirEntry]) -> Self:
"""Clear all nodes under the given node.
@@ -202,6 +213,7 @@ def reset_node(
"""Clear the subtree and reset the given node.
Args:
+ node: The node to reset.
label: The label for the node.
data: Optional data for the node.
@@ -213,16 +225,20 @@ def reset_node(
node.data = data
return self
- def reload_node(self, node: TreeNode[DirEntry]) -> None:
+ def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete:
"""Reload the given node's contents.
+ The return value may be awaited to ensure the DirectoryTree has reached
+ a stable state and is no longer performing any node reloading (of this node
+ or any other nodes).
+
Args:
node: The node to reload.
"""
self.reset_node(
node, str(node.data.path.name), DirEntry(self.PATH(node.data.path))
)
- self._add_to_load_queue(node)
+ return self._add_to_load_queue(node)
def validate_path(self, path: str | Path) -> Path:
"""Ensure that the path is of the `Path` type.
@@ -239,13 +255,13 @@ def validate_path(self, path: str | Path) -> Path:
"""
return self.PATH(path)
- def watch_path(self) -> None:
+ async def watch_path(self) -> None:
"""Watch for changes to the `path` of the directory tree.
If the path is changed the directory tree will be repopulated using
the new value as the root.
"""
- self.reload()
+ await self.reload()
def process_label(self, label: TextType) -> Text:
"""Process a str or Text into a label. Maybe overridden in a subclass to modify how labels are rendered.
@@ -421,16 +437,17 @@ async def _loader(self) -> None:
# the tree.
if content:
self._populate_node(node, content)
- # Mark this iteration as done.
- self._load_queue.task_done()
+ finally:
+ # Mark this iteration as done.
+ self._load_queue.task_done()
- def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
+ async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
event.stop()
dir_entry = event.node.data
if dir_entry is None:
return
if self._safe_is_dir(dir_entry.path):
- self._add_to_load_queue(event.node)
+ await self._add_to_load_queue(event.node)
else:
self.post_message(self.FileSelected(event.node, dir_entry.path))
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index c21afb690e..11b0946d28 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -19,6 +19,7 @@
from ..message import Message
from ..reactive import reactive
from ..suggester import Suggester, SuggestionReady
+from ..timer import Timer
from ..validation import ValidationResult, Validator
from ..widget import Widget
@@ -157,7 +158,7 @@ class Input(Widget, can_focus=True):
}
"""
- cursor_blink = reactive(True)
+ cursor_blink = reactive(True, init=False)
value = reactive("", layout=True, init=False)
input_scroll_offset = reactive(0)
cursor_position = reactive(0)
@@ -255,6 +256,9 @@ def __init__(
if value is not None:
self.value = value
+ self._blink_timer: Timer | None = None
+ """Timer controlling the blinking of the cursor, instantiated in `on_mount`."""
+
self.placeholder = placeholder
self.highlighter = highlighter
self.password = password
@@ -330,6 +334,15 @@ def _watch_cursor_position(self) -> None:
self.app.cursor_position = self.cursor_screen_offset
+ def _watch_cursor_blink(self, blink: bool) -> None:
+ """Ensure we handle updating the cursor blink at runtime."""
+ if self._blink_timer is not None:
+ if blink:
+ self._blink_timer.resume()
+ else:
+ self._cursor_visible = True
+ self._blink_timer.pause()
+
@property
def cursor_screen_offset(self) -> Offset:
"""The offset of the cursor of this input in screen-space. (x, y)/(column, row)"""
@@ -419,27 +432,27 @@ def _toggle_cursor(self) -> None:
self._cursor_visible = not self._cursor_visible
def _on_mount(self, _: Mount) -> None:
- self.blink_timer = self.set_interval(
+ self._blink_timer = self.set_interval(
0.5,
self._toggle_cursor,
pause=not (self.cursor_blink and self.has_focus),
)
def _on_blur(self, _: Blur) -> None:
- self.blink_timer.pause()
+ self._blink_timer.pause()
if "blur" in self.validate_on:
self.validate(self.value)
def _on_focus(self, _: Focus) -> None:
self.cursor_position = len(self.value)
if self.cursor_blink:
- self.blink_timer.resume()
+ self._blink_timer.resume()
self.app.cursor_position = self.cursor_screen_offset
async def _on_key(self, event: events.Key) -> None:
self._cursor_visible = True
if self.cursor_blink:
- self.blink_timer.reset()
+ self._blink_timer.reset()
if event.is_printable:
event.stop()
diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py
index bdae0c6a72..a3a1160f29 100644
--- a/src/textual/widgets/_loading_indicator.py
+++ b/src/textual/widgets/_loading_indicator.py
@@ -30,6 +30,26 @@ class LoadingIndicator(Widget):
}
"""
+ def __init__(
+ self,
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ):
+ """Initialize a loading indicator.
+
+ Args:
+ name: The name of the widget.
+ id: The ID of the widget in the DOM.
+ classes: The CSS classes for the widget.
+ disabled: Whether the widget is disabled or not.
+ """
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+
+ self._start_time: float = 0.0
+ """The time the loading indicator was mounted (a Unix timestamp)."""
+
def apply(self, widget: Widget) -> AwaitMount:
"""Apply the loading indicator to a `widget`.
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
index 3dafb5579f..245e3feccf 100644
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -3,17 +3,16 @@
from asyncio import gather
from dataclasses import dataclass
from itertools import zip_longest
-from typing import Generator
from rich.repr import Result
from rich.text import Text, TextType
from ..app import ComposeResult
-from ..await_remove import AwaitRemove
+from ..await_complete import AwaitComplete
from ..css.query import NoMatches
from ..message import Message
from ..reactive import reactive
-from ..widget import AwaitMount, Widget
+from ..widget import Widget
from ._content_switcher import ContentSwitcher
from ._tabs import Tab, Tabs
@@ -104,25 +103,6 @@ def _watch_disabled(self, disabled: bool) -> None:
self.post_message(self.Disabled(self) if disabled else self.Enabled(self))
-class AwaitTabbedContent:
- """An awaitable returned by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs."""
-
- def __init__(self, *awaitables: AwaitMount | AwaitRemove) -> None:
- """Initialise the awaitable.
-
- Args:
- *awaitables: The collection of awaitables to await.
- """
- super().__init__()
- self._awaitables = awaitables
-
- def __await__(self) -> Generator[None, None, None]:
- async def await_tabbed_content() -> None:
- await gather(*self._awaitables)
-
- return await_tabbed_content().__await__()
-
-
class TabbedContent(Widget):
"""A container with associated tabs to toggle content visibility."""
@@ -280,7 +260,7 @@ def add_pane(
*,
before: TabPane | str | None = None,
after: TabPane | str | None = None,
- ) -> AwaitTabbedContent:
+ ) -> AwaitComplete:
"""Add a new pane to the tabbed content.
Args:
@@ -289,7 +269,7 @@ def add_pane(
after: Optional pane or pane ID to add the pane after.
Returns:
- An awaitable object that waits for the pane to be added.
+ An optionally awaitable object that waits for the pane to be added.
Raises:
Tabs.TabError: If there is a problem with the addition request.
@@ -306,23 +286,24 @@ def add_pane(
pane = self._set_id(pane, tabs.tab_count + 1)
assert pane.id is not None
pane.display = False
- return AwaitTabbedContent(
+ return AwaitComplete(
tabs.add_tab(ContentTab(pane._title, pane.id), before=before, after=after),
self.get_child_by_type(ContentSwitcher).mount(pane),
)
- def remove_pane(self, pane_id: str) -> AwaitTabbedContent:
+ def remove_pane(self, pane_id: str) -> AwaitComplete:
"""Remove a given pane from the tabbed content.
Args:
pane_id: The ID of the pane to remove.
Returns:
- An awaitable object that waits for the pane to be removed.
+ An optionally awaitable object that waits for the pane to be removed
+ and the Cleared message to be posted.
"""
- removals = [self.get_child_by_type(Tabs).remove_tab(pane_id)]
+ removal_awaitables = [self.get_child_by_type(Tabs).remove_tab(pane_id)]
try:
- removals.append(
+ removal_awaitables.append(
self.get_child_by_type(ContentSwitcher)
.get_child_by_id(pane_id)
.remove()
@@ -331,25 +312,26 @@ def remove_pane(self, pane_id: str) -> AwaitTabbedContent:
# It's possible that the content itself may have gone away via
# other means; so allow that to be a no-op.
pass
- await_remove = AwaitTabbedContent(*removals)
async def _remove_content(cleared_message: TabbedContent.Cleared) -> None:
- await await_remove
+ await gather(*removal_awaitables)
if self.tab_count == 0:
self.post_message(cleared_message)
- # Note that I create the message out here, rather than in
+ # Note that I create the Cleared message out here, rather than in
# _remove_content, to ensure that the message's internal
# understanding of who the sender is is correct.
- #
# https://github.com/Textualize/textual/issues/2750
- self.call_after_refresh(_remove_content, self.Cleared(self))
+ return AwaitComplete(_remove_content(self.Cleared(self)))
- return await_remove
+ def clear_panes(self) -> AwaitComplete:
+ """Remove all the panes in the tabbed content.
- def clear_panes(self) -> AwaitTabbedContent:
- """Remove all the panes in the tabbed content."""
- await_clear = AwaitTabbedContent(
+ Returns:
+ An optionally awaitable object which waits for all panes to be removed
+ and the Cleared message to be posted.
+ """
+ await_clear = gather(
self.get_child_by_type(Tabs).clear(),
self.get_child_by_type(ContentSwitcher).remove_children(),
)
@@ -358,14 +340,11 @@ async def _clear_content(cleared_message: TabbedContent.Cleared) -> None:
await await_clear
self.post_message(cleared_message)
- # Note that I create the message out here, rather than in
+ # Note that I create the Cleared message out here, rather than in
# _clear_content, to ensure that the message's internal
# understanding of who the sender is is correct.
- #
# https://github.com/Textualize/textual/issues/2750
- self.call_after_refresh(_clear_content, self.Cleared(self))
-
- return await_clear
+ return AwaitComplete(_clear_content(self.Cleared(self)))
def compose_add_child(self, widget: Widget) -> None:
"""When using the context manager compose syntax, we want to attach nodes to the switcher.
diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py
index ca03433644..ab54de158c 100644
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import asyncio
+from asyncio import create_task
from dataclasses import dataclass
from typing import ClassVar
@@ -9,7 +11,7 @@
from .. import events
from ..app import ComposeResult, RenderResult
-from ..await_remove import AwaitRemove
+from ..await_complete import AwaitComplete
from ..binding import Binding, BindingType
from ..containers import Container, Horizontal, Vertical
from ..css.query import NoMatches
@@ -18,7 +20,7 @@
from ..message import Message
from ..reactive import reactive
from ..renderables.bar import Bar
-from ..widget import AwaitMount, Widget
+from ..widget import Widget
from ..widgets import Static
@@ -362,7 +364,6 @@ def _potentially_active_tabs(self) -> list[Tab]:
@property
def _next_active(self) -> Tab | None:
"""Next tab to make active if the active tab is removed."""
- active_tab = self.active_tab
tabs = self._potentially_active_tabs
if self.active_tab is None:
return None
@@ -386,7 +387,7 @@ def add_tab(
*,
before: Tab | str | None = None,
after: Tab | str | None = None,
- ) -> AwaitMount:
+ ) -> AwaitComplete:
"""Add a new tab to the end of the tab list.
Args:
@@ -395,7 +396,8 @@ def add_tab(
after: Optional tab or tab ID to add the tab after.
Returns:
- An awaitable object that waits for the tab to be mounted.
+ An optionally awaitable object that waits for the tab to be mounted and
+ internal state to be fully updated to reflect the new tab.
Raises:
Tabs.TabError: If there is a problem with the addition request.
@@ -447,17 +449,23 @@ def add_tab(
async def refresh_active() -> None:
"""Wait for things to be mounted before highlighting."""
+ await mount_await
self.active = tab_widget.id or ""
self._highlight_active(animate=False)
self.post_message(activated_message)
- self.call_after_refresh(refresh_active)
+ return AwaitComplete(refresh_active())
elif before or after:
- self.call_after_refresh(self._highlight_active, animate=False)
- return mount_await
+ async def refresh_active() -> None:
+ await mount_await
+ self._highlight_active(animate=False)
+
+ return AwaitComplete(refresh_active())
+
+ return AwaitComplete(mount_await())
- def clear(self) -> AwaitRemove:
+ def clear(self) -> AwaitComplete:
"""Clear all the tabs.
Returns:
@@ -467,50 +475,50 @@ def clear(self) -> AwaitRemove:
underline.highlight_start = 0
underline.highlight_end = 0
self.call_after_refresh(self.post_message, self.Cleared(self))
- return self.query("#tabs-list > Tab").remove()
+ self.active = ""
+ return AwaitComplete(self.query("#tabs-list > Tab").remove()())
- def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitRemove:
+ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete:
"""Remove a tab.
Args:
tab_or_id: The Tab to remove or its id.
Returns:
- An awaitable object that waits for the tab to be removed.
+ An optionally awaitable object that waits for the tab to be removed.
"""
- if tab_or_id is None:
- return self.app._remove_nodes([], None)
+ if not tab_or_id:
+ return AwaitComplete(self.app._remove_nodes([], None)())
+
if isinstance(tab_or_id, Tab):
remove_tab = tab_or_id
else:
try:
remove_tab = self.query_one(f"#tabs-list > #{tab_or_id}", Tab)
except NoMatches:
- return self.app._remove_nodes([], None)
- removing_active_tab = remove_tab.has_class("-active")
+ return AwaitComplete(self.app._remove_nodes([], None)())
+ removing_active_tab = remove_tab.has_class("-active")
next_tab = self._next_active
- result_message: Tabs.Cleared | Tabs.TabActivated | None = None
- if removing_active_tab and next_tab is not None:
- result_message = self.TabActivated(self, next_tab)
- elif self.tab_count == 1:
- result_message = self.Cleared(self)
-
remove_await = remove_tab.remove()
+ highlight_updated = asyncio.Event()
+
async def do_remove() -> None:
"""Perform the remove after refresh so the underline bar gets new positions."""
await remove_await
- if removing_active_tab:
- if next_tab is not None:
- next_tab.add_class("-active")
- self.call_after_refresh(self._highlight_active, animate=True)
- if result_message is not None:
- self.post_message(result_message)
+ if next_tab is None:
+ self.active = ""
+ elif removing_active_tab:
+ self.active = next_tab.id
+ next_tab.add_class("-active")
+
+ highlight_updated.set()
- self.call_after_refresh(do_remove)
+ async def wait_for_highlight_update() -> None:
+ await highlight_updated.wait()
- return remove_await
+ return AwaitComplete(do_remove(), wait_for_highlight_update())
def validate_active(self, active: str) -> str:
"""Check id assigned to active attribute is a valid tab."""
@@ -554,7 +562,7 @@ def watch_active(self, previously_active: str, active: str) -> None:
return
self.query("#tabs-list > Tab.-active").remove_class("-active")
active_tab.add_class("-active")
- self.call_later(self._highlight_active, animate=previously_active != "")
+ self._highlight_active(animate=previously_active != "")
self.post_message(self.TabActivated(self, active_tab))
else:
underline = self.query_one(Underline)
diff --git a/tests/notifications/test_app_notifications.py b/tests/notifications/test_app_notifications.py
index 608fde812f..d5230bd919 100644
--- a/tests/notifications/test_app_notifications.py
+++ b/tests/notifications/test_app_notifications.py
@@ -1,4 +1,4 @@
-from time import sleep
+import asyncio
from textual.app import App
@@ -34,12 +34,12 @@ async def test_app_with_removing_notifications() -> None:
async def test_app_with_notifications_that_expire() -> None:
"""Notifications should expire from an app."""
async with NotificationApp().run_test() as pilot:
- for n in range(100):
- pilot.app.notify("test", timeout=(0.5 if bool(n % 2) else 60))
- await pilot.pause()
- assert len(pilot.app._notifications) == 100
- sleep(0.6)
- assert len(pilot.app._notifications) == 50
+ for n in range(10):
+ pilot.app.notify("test", timeout=(0.01 if bool(n % 2) else 60))
+
+ # Wait until the 0.01 timeout expires on all notifications (plus some leeway)
+ await asyncio.sleep(0.25)
+ assert len(pilot.app._notifications) == 5
async def test_app_clearing_notifications() -> None:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 57542020b8..bc9181d4b7 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -33754,141 +33754,139 @@
font-weight: 700;
}
- .terminal-2372579992-matrix {
+ .terminal-4283085444-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2372579992-title {
+ .terminal-4283085444-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2372579992-r1 { fill: #05080f }
- .terminal-2372579992-r2 { fill: #e1e1e1 }
- .terminal-2372579992-r3 { fill: #c5c8c6 }
- .terminal-2372579992-r4 { fill: #1e2226;font-weight: bold }
- .terminal-2372579992-r5 { fill: #35393d }
- .terminal-2372579992-r6 { fill: #454a50 }
- .terminal-2372579992-r7 { fill: #fea62b }
- .terminal-2372579992-r8 { fill: #e2e3e3;font-weight: bold }
- .terminal-2372579992-r9 { fill: #000000 }
- .terminal-2372579992-r10 { fill: #e2e3e3 }
- .terminal-2372579992-r11 { fill: #14191f }
+ .terminal-4283085444-r1 { fill: #454a50 }
+ .terminal-4283085444-r2 { fill: #e1e1e1 }
+ .terminal-4283085444-r3 { fill: #c5c8c6 }
+ .terminal-4283085444-r4 { fill: #24292f;font-weight: bold }
+ .terminal-4283085444-r5 { fill: #000000 }
+ .terminal-4283085444-r6 { fill: #fea62b }
+ .terminal-4283085444-r7 { fill: #e2e3e3;font-weight: bold }
+ .terminal-4283085444-r8 { fill: #e2e3e3 }
+ .terminal-4283085444-r9 { fill: #14191f }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- BorderApp
+ BorderApp
-
-
-
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- ascii
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔+------------------- ascii --------------------+
- blank||
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁||
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|I must not fear.|
- dashed|Fear is the mind-killer.|
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|Fear is the little-death that brings |
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|total obliteration.|
- double|I will face my fear.|
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅|I will permit it to pass over me and |
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|through me.|
- heavy|And when it has gone past, I will turn|
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|the inner eye to see its path.|
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|Where the fear has gone there will be |
- hidden|nothing. Only I will remain.|
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁||
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔||
- hkey+----------------------------------------------+
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- inner
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+
+
+
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ ascii
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔+------------------- ascii --------------------+
+ blank||
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁||
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|I must not fear.|
+ dashed|Fear is the mind-killer.|
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|Fear is the little-death that brings |
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|total obliteration.|
+ double|I will face my fear.|
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅|I will permit it to pass over me and |
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|through me.|
+ heavy|And when it has gone past, I will turn|
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁|the inner eye to see its path.|
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔|Where the fear has gone there will be |
+ hidden|nothing. Only I will remain.|
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁||
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔||
+ hkey+----------------------------------------------+
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ inner
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 1f335b7bad..c43ee6a35d 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -5,7 +5,7 @@
from tests.snapshot_tests.language_snippets import SNIPPETS
from textual.widgets.text_area import Selection, BUILTIN_LANGUAGES
-from textual.widgets import TextArea
+from textual.widgets import TextArea, Input, Button
from textual.widgets.text_area import TextAreaTheme
# These paths should be relative to THIS directory.
@@ -102,7 +102,10 @@ def test_input_validation(snap_compare):
def test_input_suggestions(snap_compare):
- assert snap_compare(SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[])
+ async def run_before(pilot):
+ pilot.app.query_one(Input).cursor_blink = False
+
+ assert snap_compare(SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[], run_before=run_before)
def test_buttons_render(snap_compare):
@@ -669,6 +672,9 @@ def test_command_palette(snap_compare) -> None:
from textual.command import CommandPalette
async def run_before(pilot) -> None:
+ palette = pilot.app.query_one(CommandPalette)
+ palette_input = palette.query_one(Input)
+ palette_input.cursor_blink = False
await pilot.press("ctrl+backslash")
await pilot.press("A")
await pilot.app.query_one(CommandPalette).workers.wait_for_complete()
@@ -680,7 +686,16 @@ async def run_before(pilot) -> None:
def test_textual_dev_border_preview(snap_compare):
- assert snap_compare(SNAPSHOT_APPS_DIR / "dev_previews_border.py", press=["enter"])
+ async def run_before(pilot):
+ buttons = pilot.app.query(Button)
+ for button in buttons:
+ button.active_effect_duration = 0
+
+ assert snap_compare(
+ SNAPSHOT_APPS_DIR / "dev_previews_border.py",
+ press=["enter"],
+ run_before=run_before,
+ )
def test_textual_dev_colors_preview(snap_compare):
diff --git a/tests/test_animation.py b/tests/test_animation.py
index 3aca1d802f..5428f7a91a 100644
--- a/tests/test_animation.py
+++ b/tests/test_animation.py
@@ -51,8 +51,8 @@ async def test_scheduling_animation() -> None:
styles.animate("background", "white", delay=delay, duration=0)
- await pilot.pause(0.9 * delay)
- assert styles.background.rgb == (0, 0, 0) # Still black
+ # Still black immediately after call, animation hasn't started yet due to `delay`
+ assert styles.background.rgb == (0, 0, 0)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (255, 255, 255)
@@ -153,8 +153,8 @@ async def test_schedule_reverse_animations() -> None:
assert styles.background.rgb == (0, 0, 0)
# Now, the actual test is to make sure we go back to black if scheduling both at once.
- styles.animate("background", "white", delay=0.05, duration=0.01)
- await pilot.pause()
+ styles.animate("background", "white", delay=0.025, duration=0.05)
+ # While the black -> white animation runs, start the white -> black animation.
styles.animate("background", "black", delay=0.05, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (0, 0, 0)
diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py
index c5f11d08f0..2c7cdcbb66 100644
--- a/tests/test_screen_modes.py
+++ b/tests/test_screen_modes.py
@@ -111,13 +111,13 @@ async def test_switch_unknown_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
with pytest.raises(UnknownModeError):
- app.switch_mode("unknown mode here")
+ await app.switch_mode("unknown mode here")
async def test_remove_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
- app.switch_mode("two")
+ await app.switch_mode("two")
await pilot.pause()
assert str(app.screen.query_one(Label).renderable) == "two"
app.remove_mode("one")
@@ -135,7 +135,7 @@ async def test_add_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
app.add_mode("three", BaseScreen("three"))
- app.switch_mode("three")
+ await app.switch_mode("three")
await pilot.pause()
assert str(app.screen.query_one(Label).renderable) == "three"
@@ -172,47 +172,6 @@ async def test_screen_stack_preserved(ModesApp: Type[App]):
await pilot.press("o")
-async def test_inactive_stack_is_alive():
- """This tests that timers in screens outside the active stack keep going."""
- pings = []
-
- class FastCounter(Screen[None]):
- def compose(self) -> ComposeResult:
- yield Label("fast")
-
- def on_mount(self) -> None:
- self.call_later(self.set_interval, 0.01, self.ping)
-
- def ping(self) -> None:
- pings.append(str(self.app.query_one(Label).renderable))
-
- def key_s(self):
- self.app.switch_mode("smile")
-
- class SmileScreen(Screen[None]):
- def compose(self) -> ComposeResult:
- yield Label(":)")
-
- def key_s(self):
- self.app.switch_mode("fast")
-
- class ModesApp(App[None]):
- MODES = {
- "fast": FastCounter,
- "smile": SmileScreen,
- }
-
- def on_mount(self) -> None:
- self.switch_mode("fast")
-
- app = ModesApp()
- async with app.run_test() as pilot:
- await pilot.press("s")
- assert str(app.query_one(Label).renderable) == ":)"
- await pilot.press("s")
- assert ":)" in pings
-
-
async def test_multiple_mode_callbacks():
written = []
diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py
index f0b029a7f6..1865a70b9d 100644
--- a/tests/test_tabbed_content.py
+++ b/tests/test_tabbed_content.py
@@ -169,11 +169,9 @@ def compose(self) -> ComposeResult:
assert tabbed_content.active == ""
assert tabbed_content.tab_count == 0
await tabbed_content.add_pane(TabPane("Test 1", id="test-1"))
- await pilot.pause()
assert tabbed_content.tab_count == 1
assert tabbed_content.active == "test-1"
await tabbed_content.add_pane(TabPane("Test 2", id="test-2"))
- await pilot.pause()
assert tabbed_content.tab_count == 2
assert tabbed_content.active == "test-1"
@@ -191,11 +189,9 @@ def compose(self) -> ComposeResult:
assert tabbed_content.tab_count == 3
assert tabbed_content.active == "initial-1"
await tabbed_content.add_pane(TabPane("Test 4", id="test-1"))
- await pilot.pause()
assert tabbed_content.tab_count == 4
assert tabbed_content.active == "initial-1"
await tabbed_content.add_pane(TabPane("Test 5", id="test-2"))
- await pilot.pause()
assert tabbed_content.tab_count == 5
assert tabbed_content.active == "initial-1"
@@ -211,7 +207,6 @@ def compose(self) -> ComposeResult:
assert tabbed_content.tab_count == 1
assert tabbed_content.active == "initial-1"
await tabbed_content.add_pane(TabPane("Added", id="new-1"), before="initial-1")
- await pilot.pause()
assert tabbed_content.tab_count == 2
assert tabbed_content.active == "initial-1"
assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [
@@ -234,7 +229,6 @@ def compose(self) -> ComposeResult:
TabPane("Added", id="new-1"),
before=pilot.app.query_one("TabPane#initial-1", TabPane),
)
- await pilot.pause()
assert tabbed_content.tab_count == 2
assert tabbed_content.active == "initial-1"
assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [
@@ -270,7 +264,6 @@ def compose(self) -> ComposeResult:
assert tabbed_content.tab_count == 1
assert tabbed_content.active == "initial-1"
await tabbed_content.add_pane(TabPane("Added", id="new-1"), after="initial-1")
- await pilot.pause()
assert tabbed_content.tab_count == 2
assert tabbed_content.active == "initial-1"
assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [
diff --git a/tests/test_tabs.py b/tests/test_tabs.py
index 383a262d8d..1269820115 100644
--- a/tests/test_tabs.py
+++ b/tests/test_tabs.py
@@ -214,25 +214,21 @@ def compose(self) -> ComposeResult:
assert tabs.active_tab.id == "tab-1"
await tabs.remove_tab("tab-1")
- await pilot.pause()
assert tabs.tab_count == 3
assert tabs.active_tab is not None
assert tabs.active_tab.id == "tab-2"
await tabs.remove_tab(tabs.query_one("#tab-2", Tab))
- await pilot.pause()
assert tabs.tab_count == 2
assert tabs.active_tab is not None
assert tabs.active_tab.id == "tab-3"
await tabs.remove_tab("tab-does-not-exist")
- await pilot.pause()
assert tabs.tab_count == 2
assert tabs.active_tab is not None
assert tabs.active_tab.id == "tab-3"
await tabs.remove_tab(None)
- await pilot.pause()
assert tabs.tab_count == 2
assert tabs.active_tab is not None
assert tabs.active_tab.id == "tab-3"
@@ -257,25 +253,21 @@ def compose(self) -> ComposeResult:
assert tabs.active_tab.id == "tab-1"
await tabs.remove_tab("tab-4")
- await pilot.pause()
assert tabs.tab_count == 3
assert tabs.active_tab is not None
assert tabs.active_tab.id == "tab-1"
await tabs.remove_tab("tab-3")
- await pilot.pause()
assert tabs.tab_count == 2
assert tabs.active_tab is not None
assert tabs.active_tab.id == "tab-1"
await tabs.remove_tab("tab-2")
- await pilot.pause()
assert tabs.tab_count == 1
assert tabs.active_tab is not None
assert tabs.active_tab.id == "tab-1"
await tabs.remove_tab("tab-1")
- await pilot.pause()
assert tabs.tab_count == 0
assert tabs.active_tab is None
@@ -447,7 +439,8 @@ async def test_remove_tabs_messages():
tabs = pilot.app.query_one(Tabs)
for n in range(4):
await tabs.remove_tab(f"tab-{n+1}")
- await pilot.pause()
+
+ await pilot.pause()
assert pilot.app.intended_handlers == [
"on_tabs_tab_activated",
"on_tabs_tab_activated",
@@ -463,7 +456,8 @@ async def test_reverse_remove_tabs_messages():
tabs = pilot.app.query_one(Tabs)
for n in reversed(range(4)):
await tabs.remove_tab(f"tab-{n+1}")
- await pilot.pause()
+
+ await pilot.pause()
assert pilot.app.intended_handlers == [
"on_tabs_tab_activated",
"on_tabs_cleared",