From 2d4183ffaa71d3ef1ce9772b448cd75e05ba96ca Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 6 Jun 2023 12:02:39 +0100 Subject: [PATCH 01/32] Clean up a type warning about tab ID in _on_tabs_tab_activated --- src/textual/widgets/_tabbed_content.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 689ce8e800..b908fe658a 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -207,9 +207,10 @@ def compose_add_child(self, widget: Widget) -> None: def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: """User clicked a tab.""" + assert isinstance(event.tab, ContentTab) + assert isinstance(event.tab.id, str) event.stop() switcher = self.get_child_by_type(ContentSwitcher) - assert isinstance(event.tab, ContentTab) switcher.current = event.tab.id self.active = event.tab.id self.post_message( From 69d86fb7649a58cba06b1a6a15debf1514b1c46a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 6 Jun 2023 12:14:24 +0100 Subject: [PATCH 02/32] Make the active tab watcher private --- src/textual/widgets/_tabbed_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index b908fe658a..3793a189c8 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -224,7 +224,7 @@ def _on_tabs_cleared(self, event: Tabs.Cleared) -> None: """All tabs were removed.""" event.stop() - def watch_active(self, active: str) -> None: + def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" with self.prevent(Tabs.TabActivated): self.get_child_by_type(Tabs).active = active From 290351db10c2b55dbc50917f0b960bca897a9d20 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 6 Jun 2023 14:26:40 +0100 Subject: [PATCH 03/32] Add an add_pane method to TabbedContent See #2710. --- src/textual/widgets/_tabbed_content.py | 44 +++++++++++++++++--------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 3793a189c8..f6fd4bec57 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -8,7 +8,7 @@ from ..app import ComposeResult from ..message import Message from ..reactive import reactive -from ..widget import Widget +from ..widget import AwaitMount, Widget from ._content_switcher import ContentSwitcher from ._tabs import Tab, Tabs @@ -157,27 +157,28 @@ def validate_active(self, active: str) -> str: raise ValueError("'active' tab must not be empty string.") return active - def compose(self) -> ComposeResult: - """Compose the tabbed content.""" + @staticmethod + def _set_id(content: TabPane, new_id: int) -> TabPane: + """Set an id on the content, if not already present. - def set_id(content: TabPane, new_id: str) -> TabPane: - """Set an id on the content, if not already present. + Args: + content: a TabPane. + new_id: Numeric ID to make the pane ID from. - Args: - content: a TabPane. - new_id: New `is` attribute, if it is not already set. + Returns: + The same TabPane. + """ + if content.id is None: + content.id = f"tab-{new_id}" + return content - Returns: - The same TabPane. - """ - if content.id is None: - content.id = new_id - return content + def compose(self) -> ComposeResult: + """Compose the tabbed content.""" # Wrap content in a `TabPane` if required. pane_content = [ ( - set_id(content, f"tab-{index}") + self._set_id(content, index) if isinstance(content, TabPane) else TabPane( title or self.render_str(f"Tab {index}"), content, id=f"tab-{index}" @@ -197,6 +198,19 @@ def set_id(content: TabPane, new_id: str) -> TabPane: with ContentSwitcher(initial=self._initial or None): yield from pane_content + def add_pane(self, pane: TabPane) -> AwaitMount: + """Add a new pane to the tabbed content. + + Args: + pane: The pane to add. + """ + tabs = self.get_child_by_type(Tabs) + pane = self._set_id(pane, tabs.tab_count + 1) + assert pane.id is not None + tabs.add_tab(ContentTab(pane._title, pane.id)) + pane.display = False + return self.get_child_by_type(ContentSwitcher).mount(pane) + def compose_add_child(self, widget: Widget) -> None: """When using the context manager compose syntax, we want to attach nodes to the switcher. From 5cf50f7083fd3488bb3a8caeda99d5c55dbdd6b8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 6 Jun 2023 14:55:51 +0100 Subject: [PATCH 04/32] Add a remove_pane method to TabbedContent See #2710. --- src/textual/widgets/_tabbed_content.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index f6fd4bec57..4f90af6d4e 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -211,6 +211,17 @@ def add_pane(self, pane: TabPane) -> AwaitMount: pane.display = False return self.get_child_by_type(ContentSwitcher).mount(pane) + def remove_pane(self, pane_id: str) -> None: + """Remove a given pane from the tabbed content. + + Args: + pane_id: The ID of the pane to remove. + """ + self.get_child_by_type(Tabs).remove_tab(pane_id) + self.call_after_refresh( + self.get_child_by_type(ContentSwitcher).get_child_by_id(pane_id).remove + ) + def compose_add_child(self, widget: Widget) -> None: """When using the context manager compose syntax, we want to attach nodes to the switcher. @@ -237,6 +248,7 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: def _on_tabs_cleared(self, event: Tabs.Cleared) -> None: """All tabs were removed.""" event.stop() + self.get_child_by_type(ContentSwitcher).current = None def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" From 81edb863fbd383976a9fce9fda9d24b7d8968f61 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 6 Jun 2023 16:30:45 +0100 Subject: [PATCH 05/32] Only error out when active is empty and there are tabs available If, on the other hand, we set active to empty when there is no content to be tabbed, then we let it slide. --- src/textual/widgets/_tabbed_content.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 4f90af6d4e..a8a69d9968 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -151,10 +151,10 @@ def validate_active(self, active: str) -> str: Value of `active`. Raises: - ValueError: If the active attribute is set to empty string. + ValueError: If the active attribute is set to empty string when there are tabs available. """ - if not active: - raise ValueError("'active' tab must not be empty string.") + if not active and self.get_child_by_type(ContentSwitcher).current: + raise ValueError(f"'active' tab must not be empty string.") return active @staticmethod From 300401c4b68074bcaf616301671bf04ca5c6b800 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 6 Jun 2023 16:31:35 +0100 Subject: [PATCH 06/32] Set active to empty if there are no tabs left --- src/textual/widgets/_tabbed_content.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index a8a69d9968..d4fe0ebbe7 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -249,6 +249,7 @@ def _on_tabs_cleared(self, event: Tabs.Cleared) -> None: """All tabs were removed.""" event.stop() self.get_child_by_type(ContentSwitcher).current = None + self.active = "" def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" From 8f4b40ef24ac1df6cadf51cdd510275c9fbfbdbf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 6 Jun 2023 16:41:43 +0100 Subject: [PATCH 07/32] Add some tests for adding/removing tabs to TabbedContent --- tests/test_tabbed_content.py | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 216a6bf349..e771f695d0 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -156,3 +156,56 @@ def on_tabbed_content_tab_activated( await pilot.pause() assert isinstance(app.message, TabbedContent.TabActivated) assert app.message.tab.label.plain == "bar" + + +async def test_tabbed_content_add_after_from_empty(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + yield TabbedContent() + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.active == "" + await tabbed_content.add_pane(TabPane("Test 1", id="test-1")) + assert tabbed_content.active == "test-1" + await tabbed_content.add_pane(TabPane("Test 2", id="test-2")) + assert tabbed_content.active == "test-1" + + +async def test_tabbed_content_add_after_from_composed(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + yield TabPane("Test 2", id="initial-2") + yield TabPane("Test 3", id="initial-3") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.active == "initial-1" + await tabbed_content.add_pane(TabPane("Test 4", id="test-1")) + assert tabbed_content.active == "initial-1" + await tabbed_content.add_pane(TabPane("Test 5", id="test-2")) + assert tabbed_content.active == "initial-1" + + +async def test_tabbed_content_removal(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + yield TabPane("Test 2", id="initial-2") + yield TabPane("Test 3", id="initial-3") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.active == "initial-1" + tabbed_content.remove_pane("initial-1") + await pilot.pause() + assert tabbed_content.active == "initial-2" + tabbed_content.remove_pane("initial-2") + await pilot.pause() + assert tabbed_content.active == "initial-3" + tabbed_content.remove_pane("initial-3") + await pilot.pause() + assert tabbed_content.active == "" From 780297076246bcd844cdf458ceb6751767185aa6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 07:24:18 +0100 Subject: [PATCH 08/32] Add a tab_count property to TabbedContent Mimics (and actually simply returns) Tabs.tab_count. The idea being that if people can now add and remove tabs from TabbedContent, they may want to be able to keep track of how many tabs there are. --- src/textual/widgets/_tabbed_content.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index d4fe0ebbe7..6b4983f285 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -256,3 +256,8 @@ def _watch_active(self, active: str) -> None: with self.prevent(Tabs.TabActivated): self.get_child_by_type(Tabs).active = active self.get_child_by_type(ContentSwitcher).current = active + + @property + def tab_count(self) -> int: + """Total number of tabs.""" + return self.get_child_by_type(Tabs).tab_count From feb07db4354b5efe3534396e12ebf14a0d5f2e51 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 07:49:39 +0100 Subject: [PATCH 09/32] Document the return value of TabbedContent.add_pane --- src/textual/widgets/_tabbed_content.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 6b4983f285..158dc0b474 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -203,6 +203,9 @@ def add_pane(self, pane: TabPane) -> AwaitMount: Args: pane: The pane to add. + + Returns: + An awaitable object that waits for the pane to be mounted. """ tabs = self.get_child_by_type(Tabs) pane = self._set_id(pane, tabs.tab_count + 1) From d8aa49b0462ffa787d157d98449be3d338e3464f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 08:18:08 +0100 Subject: [PATCH 10/32] Deduplicate the create of tab IDs --- src/textual/widgets/_tabbed_content.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 158dc0b474..1243cebcd8 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -177,12 +177,11 @@ def compose(self) -> ComposeResult: # Wrap content in a `TabPane` if required. pane_content = [ - ( - self._set_id(content, index) + self._set_id( + content if isinstance(content, TabPane) - else TabPane( - title or self.render_str(f"Tab {index}"), content, id=f"tab-{index}" - ) + else TabPane(title or self.render_str(f"Tab {index}"), content), + index, ) for index, (title, content) in enumerate( zip_longest(self.titles, self._tab_content), 1 From 0ac613d7fe1a4d935fa8815d6bbe608e3a16ab20 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 08:18:29 +0100 Subject: [PATCH 11/32] Add tests that check the tab count when adding and removing tabs --- tests/test_tabbed_content.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index e771f695d0..5d5565513c 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -166,9 +166,12 @@ def compose(self) -> ComposeResult: async with TabbedApp().run_test() as pilot: tabbed_content = pilot.app.query_one(TabbedContent) assert tabbed_content.active == "" + assert tabbed_content.tab_count == 0 await tabbed_content.add_pane(TabPane("Test 1", id="test-1")) + assert tabbed_content.tab_count == 1 assert tabbed_content.active == "test-1" await tabbed_content.add_pane(TabPane("Test 2", id="test-2")) + assert tabbed_content.tab_count == 2 assert tabbed_content.active == "test-1" @@ -182,10 +185,13 @@ def compose(self) -> ComposeResult: async with TabbedApp().run_test() as pilot: tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.tab_count == 3 assert tabbed_content.active == "initial-1" await tabbed_content.add_pane(TabPane("Test 4", id="test-1")) + assert tabbed_content.tab_count == 4 assert tabbed_content.active == "initial-1" await tabbed_content.add_pane(TabPane("Test 5", id="test-2")) + assert tabbed_content.tab_count == 5 assert tabbed_content.active == "initial-1" @@ -199,13 +205,17 @@ def compose(self) -> ComposeResult: async with TabbedApp().run_test() as pilot: tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.tab_count == 3 assert tabbed_content.active == "initial-1" tabbed_content.remove_pane("initial-1") await pilot.pause() + assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-2" tabbed_content.remove_pane("initial-2") await pilot.pause() + assert tabbed_content.tab_count == 1 assert tabbed_content.active == "initial-3" tabbed_content.remove_pane("initial-3") await pilot.pause() + assert tabbed_content.tab_count == 0 assert tabbed_content.active == "" From fd243f4973bdb3ba948dfbb4da837b9d1fe6bd7d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 08:40:25 +0100 Subject: [PATCH 12/32] Add a TabbedContent method to clear all panes --- src/textual/widgets/_tabbed_content.py | 5 +++++ tests/test_tabbed_content.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 1243cebcd8..af52f04dd2 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -224,6 +224,11 @@ def remove_pane(self, pane_id: str) -> None: self.get_child_by_type(ContentSwitcher).get_child_by_id(pane_id).remove ) + def clear_panes(self) -> None: + """Remove all the panes in the tabbed content.""" + self.get_child_by_type(Tabs).clear() + self.call_after_refresh(self.get_child_by_type(ContentSwitcher).remove_children) + 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/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 5d5565513c..b2dbf4ea00 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -219,3 +219,21 @@ def compose(self) -> ComposeResult: await pilot.pause() assert tabbed_content.tab_count == 0 assert tabbed_content.active == "" + + +async def test_tabbed_content_clear(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + yield TabPane("Test 2", id="initial-2") + yield TabPane("Test 3", id="initial-3") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.tab_count == 3 + assert tabbed_content.active == "initial-1" + tabbed_content.clear_panes() + await pilot.pause() + assert tabbed_content.tab_count == 0 + assert tabbed_content.active == "" From 2bf8fd7905533fc9c04b02df44db47dae21a2cc8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 12:21:17 +0100 Subject: [PATCH 13/32] Add a TabbedContent.Cleared message Much like Tabs.Cleared, this indicates that all available tabs/panes have been removed and the widget is now empty. This is especially important here as the way we remove tabs is such that we can't await their removal and then make the remove methods async (because Tabs doesn't allow for that). So the approach taken here is to send a message from TabbedContent, and delay it as much as possible, ideally once the action that's taking place *has* taken place. The reasoning is: a user may clear down all panes, then want to add some back, possibly with IDs they've used before. The clear down might not have fully happened, but we can't await it all, so the approach for the user would be to wait until the Cleared message turns up *then* repopulate. --- src/textual/widgets/_tabbed_content.py | 51 ++++++++++++++++++++++++-- tests/test_tabbed_content.py | 17 +++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index af52f04dd2..c70120ac78 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -117,6 +117,28 @@ def __rich_repr__(self) -> Result: yield self.tabbed_content yield self.tab + class Cleared(Message): + """Posted when there are no more tab panes.""" + + def __init__(self, tabbed_content: TabbedContent) -> None: + """Initialize message. + + Args: + tabbed_content: The TabbedContent widget. + """ + self.tabbed_content = tabbed_content + """The `TabbedContent` widget that contains the tab activated.""" + super().__init__() + + @property + def control(self) -> TabbedContent: + """The `TabbedContent` widget that was cleared of all tab panes. + + This is an alias for [`Cleared.tabbed_content`][textual.widgets.TabbedContent.Cleared.tabbed_content] + and is used by the [`on`][textual.on] decorator. + """ + return self.tabbed_content + def __init__( self, *titles: TextType, @@ -220,14 +242,35 @@ def remove_pane(self, pane_id: str) -> None: pane_id: The ID of the pane to remove. """ self.get_child_by_type(Tabs).remove_tab(pane_id) - self.call_after_refresh( - self.get_child_by_type(ContentSwitcher).get_child_by_id(pane_id).remove - ) + + async def _remove_content(cleared_message: TabbedContent.Cleared) -> None: + await self.get_child_by_type(ContentSwitcher).get_child_by_id( + pane_id + ).remove() + if self.tab_count == 0: + self.post_message(cleared_message) + + # Note that I create the 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)) def clear_panes(self) -> None: """Remove all the panes in the tabbed content.""" self.get_child_by_type(Tabs).clear() - self.call_after_refresh(self.get_child_by_type(ContentSwitcher).remove_children) + + async def _clear_content(cleared_message: TabbedContent.Cleared) -> None: + await self.get_child_by_type(ContentSwitcher).remove_children() + self.post_message(cleared_message) + + # Note that I create the 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(_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/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index b2dbf4ea00..e49c7d5235 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -1,6 +1,7 @@ import pytest from textual.app import App, ComposeResult +from textual.reactive import var from textual.widgets import Label, TabbedContent, TabPane @@ -197,43 +198,59 @@ def compose(self) -> ComposeResult: async def test_tabbed_content_removal(): class TabbedApp(App[None]): + cleared: var[int] = var(0) + def compose(self) -> ComposeResult: with TabbedContent(): yield TabPane("Test 1", id="initial-1") yield TabPane("Test 2", id="initial-2") yield TabPane("Test 3", id="initial-3") + def on_tabbed_content_cleared(self) -> None: + self.cleared += 1 + async with TabbedApp().run_test() as pilot: tabbed_content = pilot.app.query_one(TabbedContent) assert tabbed_content.tab_count == 3 + assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-1" tabbed_content.remove_pane("initial-1") await pilot.pause() assert tabbed_content.tab_count == 2 + assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-2" tabbed_content.remove_pane("initial-2") await pilot.pause() assert tabbed_content.tab_count == 1 + assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-3" tabbed_content.remove_pane("initial-3") await pilot.pause() assert tabbed_content.tab_count == 0 + assert pilot.app.cleared == 1 assert tabbed_content.active == "" async def test_tabbed_content_clear(): class TabbedApp(App[None]): + cleared: var[int] = var(0) + def compose(self) -> ComposeResult: with TabbedContent(): yield TabPane("Test 1", id="initial-1") yield TabPane("Test 2", id="initial-2") yield TabPane("Test 3", id="initial-3") + def on_tabbed_content_cleared(self) -> None: + self.cleared += 1 + async with TabbedApp().run_test() as pilot: tabbed_content = pilot.app.query_one(TabbedContent) assert tabbed_content.tab_count == 3 assert tabbed_content.active == "initial-1" + assert pilot.app.cleared == 0 tabbed_content.clear_panes() await pilot.pause() assert tabbed_content.tab_count == 0 assert tabbed_content.active == "" + assert pilot.app.cleared == 1 From 21d7049b2dfe5b378952cdc15dfa6e35777f931d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 12:47:03 +0100 Subject: [PATCH 14/32] Fix a copy/pasteo --- src/textual/widgets/_tabbed_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index c70120ac78..5141e066a1 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -266,7 +266,7 @@ async def _clear_content(cleared_message: TabbedContent.Cleared) -> None: self.post_message(cleared_message) # Note that I create the message out here, rather than in - # _remove_content, to ensure that the message's internal + # _clear_content, to ensure that the message's internal # understanding of who the sender is is correct. # # https://github.com/Textualize/textual/issues/2750 From a6e016d7e17c2a8a76bb4ca67d430dd4577799b6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 15:44:09 +0100 Subject: [PATCH 15/32] Experiment with an actual wait time The tests touched in this commit are working fine in CI for GNU/Linux and macOS; but fail on Windows as the message we need to come through doesn't seem to be coming through. Testing on Windows (11, in Parallels, on macOS) it seems that setting an actual time for the pauses does the trick. I'm not sure why, I thought a pause with no time ensured that all message queues were emptied before coming out of the pause. Apparently not. So this is an experiment to see if it'll pass in CI too. --- tests/test_tabbed_content.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index e49c7d5235..fbb515f3ca 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -215,17 +215,17 @@ def on_tabbed_content_cleared(self) -> None: assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-1" tabbed_content.remove_pane("initial-1") - await pilot.pause() + await pilot.pause(0.01) assert tabbed_content.tab_count == 2 assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-2" tabbed_content.remove_pane("initial-2") - await pilot.pause() + await pilot.pause(0.01) assert tabbed_content.tab_count == 1 assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-3" tabbed_content.remove_pane("initial-3") - await pilot.pause() + await pilot.pause(0.01) assert tabbed_content.tab_count == 0 assert pilot.app.cleared == 1 assert tabbed_content.active == "" @@ -250,7 +250,7 @@ def on_tabbed_content_cleared(self) -> None: assert tabbed_content.active == "initial-1" assert pilot.app.cleared == 0 tabbed_content.clear_panes() - await pilot.pause() + await pilot.pause(0.01) assert tabbed_content.tab_count == 0 assert tabbed_content.active == "" assert pilot.app.cleared == 1 From 83633ad9e0e82e4a93994ac0b517040fa3cf535d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 7 Jun 2023 15:57:36 +0100 Subject: [PATCH 16/32] Try a slightly longer pause to wait for messages --- tests/test_tabbed_content.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index fbb515f3ca..5ae39ec04b 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -215,17 +215,17 @@ def on_tabbed_content_cleared(self) -> None: assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-1" tabbed_content.remove_pane("initial-1") - await pilot.pause(0.01) + await pilot.pause(0.05) assert tabbed_content.tab_count == 2 assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-2" tabbed_content.remove_pane("initial-2") - await pilot.pause(0.01) + await pilot.pause(0.05) assert tabbed_content.tab_count == 1 assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-3" tabbed_content.remove_pane("initial-3") - await pilot.pause(0.01) + await pilot.pause(0.05) assert tabbed_content.tab_count == 0 assert pilot.app.cleared == 1 assert tabbed_content.active == "" @@ -250,7 +250,7 @@ def on_tabbed_content_cleared(self) -> None: assert tabbed_content.active == "initial-1" assert pilot.app.cleared == 0 tabbed_content.clear_panes() - await pilot.pause(0.01) + await pilot.pause(0.05) assert tabbed_content.tab_count == 0 assert tabbed_content.active == "" assert pilot.app.cleared == 1 From 2c6d09e7000b3f4ae36a37fe0f1991b268a43897 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Jun 2023 15:36:31 +0100 Subject: [PATCH 17/32] Make attempting to move away from non-existing content a no-op It's possible that we might be being asked to switch from an item of content that has actually been removed; there's no harm in making not finding the old thing a no-op. --- src/textual/widgets/_content_switcher.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py index 6f56773aa9..dd18b19c98 100644 --- a/src/textual/widgets/_content_switcher.py +++ b/src/textual/widgets/_content_switcher.py @@ -5,6 +5,7 @@ from typing import Optional from ..containers import Container +from ..css.query import NoMatches from ..events import Mount from ..reactive import reactive from ..widget import Widget @@ -84,6 +85,9 @@ def watch_current(self, old: str | None, new: str | None) -> None: """ with self.app.batch_update(): if old: - self.get_child_by_id(old).display = False + try: + self.get_child_by_id(old).display = False + except NoMatches: + pass if new: self.get_child_by_id(new).display = True From 07c445ceaf0310d32aa7e33737f19f90b972ef07 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Jun 2023 16:14:36 +0100 Subject: [PATCH 18/32] Make the TabbedContent add/remove/clear methods optionally awaitable --- src/textual/widgets/_tabbed_content.py | 59 ++++++++++++++++++++------ tests/test_tabbed_content.py | 2 + 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 5141e066a1..d80deac2ed 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -1,11 +1,13 @@ from __future__ import annotations 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 ..message import Message from ..reactive import reactive from ..widget import AwaitMount, Widget @@ -70,6 +72,26 @@ def __init__( ) +class AwaitTabbedContent: + """An awaitable return 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: + for awaitable in self._awaitables: + await awaitable + + return await_tabbed_content().__await__() + + class TabbedContent(Widget): """A container with associated tabs to toggle content visibility.""" @@ -219,34 +241,40 @@ def compose(self) -> ComposeResult: with ContentSwitcher(initial=self._initial or None): yield from pane_content - def add_pane(self, pane: TabPane) -> AwaitMount: + def add_pane(self, pane: TabPane) -> AwaitTabbedContent: """Add a new pane to the tabbed content. Args: pane: The pane to add. Returns: - An awaitable object that waits for the pane to be mounted. + An awaitable object that waits for the pane to be added. """ tabs = self.get_child_by_type(Tabs) pane = self._set_id(pane, tabs.tab_count + 1) assert pane.id is not None - tabs.add_tab(ContentTab(pane._title, pane.id)) + await_tab = tabs.add_tab(ContentTab(pane._title, pane.id)) pane.display = False - return self.get_child_by_type(ContentSwitcher).mount(pane) + return AwaitTabbedContent( + await_tab, self.get_child_by_type(ContentSwitcher).mount(pane) + ) - def remove_pane(self, pane_id: str) -> None: + def remove_pane(self, pane_id: str) -> AwaitTabbedContent: """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. """ - self.get_child_by_type(Tabs).remove_tab(pane_id) + await_remove = AwaitTabbedContent( + self.get_child_by_type(Tabs).remove_tab(pane_id), + self.get_child_by_type(ContentSwitcher).get_child_by_id(pane_id).remove(), + ) async def _remove_content(cleared_message: TabbedContent.Cleared) -> None: - await self.get_child_by_type(ContentSwitcher).get_child_by_id( - pane_id - ).remove() + await await_remove if self.tab_count == 0: self.post_message(cleared_message) @@ -257,12 +285,17 @@ async def _remove_content(cleared_message: TabbedContent.Cleared) -> None: # https://github.com/Textualize/textual/issues/2750 self.call_after_refresh(_remove_content, self.Cleared(self)) - def clear_panes(self) -> None: + return await_remove + + def clear_panes(self) -> AwaitTabbedContent: """Remove all the panes in the tabbed content.""" - self.get_child_by_type(Tabs).clear() + await_clear = AwaitTabbedContent( + self.get_child_by_type(Tabs).clear(), + self.get_child_by_type(ContentSwitcher).remove_children(), + ) async def _clear_content(cleared_message: TabbedContent.Cleared) -> None: - await self.get_child_by_type(ContentSwitcher).remove_children() + await await_clear self.post_message(cleared_message) # Note that I create the message out here, rather than in @@ -272,6 +305,8 @@ async def _clear_content(cleared_message: TabbedContent.Cleared) -> None: # https://github.com/Textualize/textual/issues/2750 self.call_after_refresh(_clear_content, self.Cleared(self)) + return await_clear + 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/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 5ae39ec04b..7610fbffd3 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -169,9 +169,11 @@ 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" From 617244ddd71293fe08a216abd8a51acdf26a27f4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Jun 2023 16:31:57 +0100 Subject: [PATCH 19/32] Simplify add_pane --- src/textual/widgets/_tabbed_content.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index d80deac2ed..fe09812367 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -253,10 +253,10 @@ def add_pane(self, pane: TabPane) -> AwaitTabbedContent: tabs = self.get_child_by_type(Tabs) pane = self._set_id(pane, tabs.tab_count + 1) assert pane.id is not None - await_tab = tabs.add_tab(ContentTab(pane._title, pane.id)) pane.display = False return AwaitTabbedContent( - await_tab, self.get_child_by_type(ContentSwitcher).mount(pane) + tabs.add_tab(ContentTab(pane._title, pane.id)), + self.get_child_by_type(ContentSwitcher).mount(pane), ) def remove_pane(self, pane_id: str) -> AwaitTabbedContent: From f21668667dd22ae5d1fde586be6fbfaadc73772f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 09:51:26 +0100 Subject: [PATCH 20/32] Be more forgiving when removing tabbed content It's possible, unlikely but possible, that the content could get removed via some other route, or out of sync, so allow for that. Don't get upset of the content has gone away when we're removing the tab that was in charge of it. --- src/textual/widgets/_tabbed_content.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index fe09812367..99fd4c3532 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -8,6 +8,7 @@ from ..app import ComposeResult from ..await_remove import AwaitRemove +from ..css.query import NoMatches from ..message import Message from ..reactive import reactive from ..widget import AwaitMount, Widget @@ -268,10 +269,18 @@ def remove_pane(self, pane_id: str) -> AwaitTabbedContent: Returns: An awaitable object that waits for the pane to be removed. """ - await_remove = AwaitTabbedContent( - self.get_child_by_type(Tabs).remove_tab(pane_id), - self.get_child_by_type(ContentSwitcher).get_child_by_id(pane_id).remove(), - ) + removals = [self.get_child_by_type(Tabs).remove_tab(pane_id)] + try: + removals.append( + self.get_child_by_type(ContentSwitcher) + .get_child_by_id(pane_id) + .remove() + ) + except NoMatches: + # 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 From ffd6db660ae75009f7ac16c3e224662c38eb4d39 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 10:05:33 +0100 Subject: [PATCH 21/32] Remove unused import --- src/textual/widgets/_tabs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 223eafd025..4cd96d010b 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar import rich.repr from rich.style import Style From 6f0356609087d80d78e737e0316ae4332c470275 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 10:27:52 +0100 Subject: [PATCH 22/32] Allow completely turning off the highlight Ideally this would use something like what #2786 intends to add, but meanwhile this solves the problem of ghost highlights in extreme situations of adding/removing tabs. --- src/textual/widgets/_tabs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 4cd96d010b..0638b69478 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -46,6 +46,8 @@ class Underline(Widget): """First cell in highlight.""" highlight_end = reactive(0) """Last cell (inclusive) in highlight.""" + show_highlight: reactive[bool] = reactive(True) + """Flag to indicate if a highlight should be shown at all.""" class Clicked(Message): """Inform ancestors the underline was clicked.""" @@ -60,7 +62,11 @@ def __init__(self, offset: Offset) -> None: @property def _highlight_range(self) -> tuple[int, int]: """Highlighted range for underline bar.""" - return (self.highlight_start, self.highlight_end) + return ( + (self.highlight_start, self.highlight_end) + if self.show_highlight + else (0, 0) + ) def render(self) -> RenderResult: """Render the bar.""" From 4af0f8304f8313de5d166c3adc367c6a915f9392 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 10:28:50 +0100 Subject: [PATCH 23/32] Turn on/off highlighting of the underline depending on tabs --- src/textual/widgets/_tabs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 0638b69478..b4f486e4a2 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -510,9 +510,11 @@ def _highlight_active(self, animate: bool = True) -> None: try: active_tab = self.query_one(f"#tabs-list > Tab.-active") except NoMatches: + underline.show_highlight = False underline.highlight_start = 0 underline.highlight_end = 0 else: + underline.show_highlight = True tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter) start, end = tab_region.column_span if animate: From f5e516dfd8663ddfb536723c5127b9a451b84662 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 10:41:26 +0100 Subject: [PATCH 24/32] Add support for adding a pane before/after another one --- src/textual/widgets/_tabbed_content.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 99fd4c3532..1738120a2c 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -242,21 +242,40 @@ def compose(self) -> ComposeResult: with ContentSwitcher(initial=self._initial or None): yield from pane_content - def add_pane(self, pane: TabPane) -> AwaitTabbedContent: + def add_pane( + self, + pane: TabPane, + *, + before: TabPane | str | None = None, + after: TabPane | str | None = None, + ) -> AwaitTabbedContent: """Add a new pane to the tabbed content. Args: pane: The pane to add. + before: Optional pane or pane ID to add the pane before. + after: Optional pane or pane ID to add the pane after. Returns: An awaitable object that waits for the pane to be added. + + Raises: + Tabs.TabError: If there is a problem with the addition request. + + Note: + Only one of `before` or `after` can be provided. If both are + provided a `Tabs.TabError` will be raised. """ + if isinstance(before, TabPane): + before = before.id + if isinstance(after, TabPane): + after = after.id tabs = self.get_child_by_type(Tabs) pane = self._set_id(pane, tabs.tab_count + 1) assert pane.id is not None pane.display = False return AwaitTabbedContent( - tabs.add_tab(ContentTab(pane._title, pane.id)), + tabs.add_tab(ContentTab(pane._title, pane.id), before=before, after=after), self.get_child_by_type(ContentSwitcher).mount(pane), ) From abc0e802d137ca51fc3506d971bc54b9b2157f06 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 10:50:57 +0100 Subject: [PATCH 25/32] Make the rest of the TabbedContent tests lean on async/await --- tests/test_tabbed_content.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 7610fbffd3..bbf809b755 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -216,18 +216,18 @@ def on_tabbed_content_cleared(self) -> None: assert tabbed_content.tab_count == 3 assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-1" - tabbed_content.remove_pane("initial-1") - await pilot.pause(0.05) + await tabbed_content.remove_pane("initial-1") + await pilot.pause() assert tabbed_content.tab_count == 2 assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-2" - tabbed_content.remove_pane("initial-2") - await pilot.pause(0.05) + await tabbed_content.remove_pane("initial-2") + await pilot.pause() assert tabbed_content.tab_count == 1 assert pilot.app.cleared == 0 assert tabbed_content.active == "initial-3" - tabbed_content.remove_pane("initial-3") - await pilot.pause(0.05) + await tabbed_content.remove_pane("initial-3") + await pilot.pause() assert tabbed_content.tab_count == 0 assert pilot.app.cleared == 1 assert tabbed_content.active == "" @@ -251,8 +251,8 @@ def on_tabbed_content_cleared(self) -> None: assert tabbed_content.tab_count == 3 assert tabbed_content.active == "initial-1" assert pilot.app.cleared == 0 - tabbed_content.clear_panes() - await pilot.pause(0.05) + await tabbed_content.clear_panes() + await pilot.pause() assert tabbed_content.tab_count == 0 assert tabbed_content.active == "" assert pilot.app.cleared == 1 From 4dd934a68da95522aa7a74ae1bd586e82070db5a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 10:58:33 +0100 Subject: [PATCH 26/32] Add a couple of missing pauses to TabbedContent unit tests These are needed to give messages time to flow. --- tests/test_tabbed_content.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index bbf809b755..b7ed743eb7 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -191,9 +191,11 @@ 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" From 146b1b8e4ce694b8fed97ef8ba51c4f6c086cb61 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 11:26:04 +0100 Subject: [PATCH 27/32] Update the ChangeLog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2604e7c19c..bb0e50471e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Class variable `CSS` to screens https://github.com/Textualize/textual/issues/2137 - Class variable `CSS_PATH` to screens https://github.com/Textualize/textual/issues/2137 - Added `cursor_foreground_priority` and `cursor_background_priority` to `DataTable` https://github.com/Textualize/textual/pull/2736 +- Added `TabbedContent.tab_count` https://github.com/Textualize/textual/pull/2751 +- Added `TabbedContnet.add_pane` https://github.com/Textualize/textual/pull/2751 +- Added `TabbedContent.remove_pane` https://github.com/Textualize/textual/pull/2751 +- Added `TabbedContent.clear_panes` https://github.com/Textualize/textual/pull/2751 +- Added `TabbedContent.Cleared` https://github.com/Textualize/textual/pull/2751 ### Fixed @@ -22,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed issue where internal data of `OptionList` could be invalid for short window after `clear_options` https://github.com/Textualize/textual/pull/2754 - Fixed `Tooltip` causing a `query_one` on a lone `Static` to fail https://github.com/Textualize/textual/issues/2723 - Nested widgets wouldn't lose focus when parent is disabled https://github.com/Textualize/textual/issues/2772 +- Fixed the `Tabs` `Underline` highlight getting "lost" in some extreme situations https://github.com/Textualize/textual/pull/2751 ### Changed From 06c2b975eee7d03a1c72db72e68aa5fd57b06599 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 11:55:30 +0100 Subject: [PATCH 28/32] Remove unnecessary f-string --- src/textual/widgets/_tabbed_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 1738120a2c..beed2f5622 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -199,7 +199,7 @@ def validate_active(self, active: str) -> str: ValueError: If the active attribute is set to empty string when there are tabs available. """ if not active and self.get_child_by_type(ContentSwitcher).current: - raise ValueError(f"'active' tab must not be empty string.") + raise ValueError("'active' tab must not be empty string.") return active @staticmethod From 282f2c6dd749fb98f73545870cdd3f8fb593c764 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 12:04:48 +0100 Subject: [PATCH 29/32] Fix a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/widgets/_tabbed_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index beed2f5622..04806506a6 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -74,7 +74,7 @@ def __init__( class AwaitTabbedContent: - """An awaitable return by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs.""" + """An awaitable returned by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs.""" def __init__(self, *awaitables: AwaitMount | AwaitRemove) -> None: """Initialise the awaitable. From 50d93b56c29e628f6b31fefccc69d17ceb03c9ae Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 14:23:33 +0100 Subject: [PATCH 30/32] Swap to asyncio.gather See https://github.com/Textualize/textual/pull/2751#discussion_r1230816478 --- src/textual/widgets/_tabbed_content.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 04806506a6..827e0a9ba7 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -1,5 +1,6 @@ from __future__ import annotations +from asyncio import gather from itertools import zip_longest from typing import Generator @@ -87,8 +88,7 @@ def __init__(self, *awaitables: AwaitMount | AwaitRemove) -> None: def __await__(self) -> Generator[None, None, None]: async def await_tabbed_content() -> None: - for awaitable in self._awaitables: - await awaitable + await gather(*self._awaitables) return await_tabbed_content().__await__() From e4b4aad467a2316f6bbfd44e1a3449892c70cfa1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 14:57:26 +0100 Subject: [PATCH 31/32] Rename a couple of TabbedContent tests So they don't get confused with actual "add after". --- tests/test_tabbed_content.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index b7ed743eb7..d5f9bc8c02 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -159,7 +159,7 @@ def on_tabbed_content_tab_activated( assert app.message.tab.label.plain == "bar" -async def test_tabbed_content_add_after_from_empty(): +async def test_tabbed_content_add_later_from_empty(): class TabbedApp(App[None]): def compose(self) -> ComposeResult: yield TabbedContent() @@ -178,7 +178,7 @@ def compose(self) -> ComposeResult: assert tabbed_content.active == "test-1" -async def test_tabbed_content_add_after_from_composed(): +async def test_tabbed_content_add_later_from_composed(): class TabbedApp(App[None]): def compose(self) -> ComposeResult: with TabbedContent(): From 832208ba838f7f9b1140f4610f4ccf96dbd37087 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Jun 2023 15:43:22 +0100 Subject: [PATCH 32/32] Add unit testing for TabbedContent adding before/after --- tests/test_tabbed_content.py | 136 ++++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index d5f9bc8c02..6e84f3addd 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -2,7 +2,7 @@ from textual.app import App, ComposeResult from textual.reactive import var -from textual.widgets import Label, TabbedContent, TabPane +from textual.widgets import Label, Tab, TabbedContent, TabPane, Tabs async def test_tabbed_content_switch_via_ui(): @@ -200,6 +200,140 @@ def compose(self) -> ComposeResult: assert tabbed_content.active == "initial-1" +async def test_tabbed_content_add_before_id(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + 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)] == [ + "new-1", + "initial-1", + ] + + +async def test_tabbed_content_add_before_pane(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.tab_count == 1 + assert tabbed_content.active == "initial-1" + await tabbed_content.add_pane( + 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)] == [ + "new-1", + "initial-1", + ] + + +async def test_tabbed_content_add_before_badly(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.tab_count == 1 + assert tabbed_content.active == "initial-1" + with pytest.raises(Tabs.TabError): + await tabbed_content.add_pane( + TabPane("Added", id="new-1"), before="unknown-1" + ) + + +async def test_tabbed_content_add_after(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + 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)] == [ + "initial-1", + "new-1", + ] + + +async def test_tabbed_content_add_after_pane(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.tab_count == 1 + assert tabbed_content.active == "initial-1" + await tabbed_content.add_pane( + TabPane("Added", id="new-1"), + after=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)] == [ + "initial-1", + "new-1", + ] + + +async def test_tabbed_content_add_after_badly(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.tab_count == 1 + assert tabbed_content.active == "initial-1" + with pytest.raises(Tabs.TabError): + await tabbed_content.add_pane( + TabPane("Added", id="new-1"), after="unknown-1" + ) + + +async def test_tabbed_content_add_before_and_after(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield TabPane("Test 1", id="initial-1") + + async with TabbedApp().run_test() as pilot: + tabbed_content = pilot.app.query_one(TabbedContent) + assert tabbed_content.tab_count == 1 + assert tabbed_content.active == "initial-1" + with pytest.raises(Tabs.TabError): + await tabbed_content.add_pane( + TabPane("Added", id="new-1"), before="initial-1", after="initial-1" + ) + + async def test_tabbed_content_removal(): class TabbedApp(App[None]): cleared: var[int] = var(0)