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