From 39b2e2a81864ff3eeeedc57b1c3088ac658ede2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 17 Aug 2023 15:46:02 +0100 Subject: [PATCH 1/6] Fix docstring. --- 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 46fecf72bc..cbcfd7ddfb 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -412,7 +412,7 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitRemove: """Remove a tab. Args: - tab_or_id: The Tab's id. + tab_or_id: The Tab to remove or its id. Returns: An awaitable object that waits for the tab to be removed. From f0c01c1061a6f339c3f65ebca1c1714c56936301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 17 Aug 2023 16:34:54 +0100 Subject: [PATCH 2/6] Add ability to enable/disable tabs. Related issues: #3088. --- CHANGELOG.md | 6 ++ src/textual/widgets/_tabbed_content.py | 42 +++++++++ src/textual/widgets/_tabs.py | 116 ++++++++++++++++++++++--- 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4911c22c1c..c9f5ddb452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added + +- Methods `TabbedContent.disable_tab` and `TabbedContent.enable_tab` https://github.com/Textualize/textual/pull/3112 +- Methods `Tabs.disable` and `Tabs.enable` https://github.com/Textualize/textual/pull/3112 +- Messages `Tab.Disabled`, `Tab.Enabled`, `Tabs.TabDisabled` and `Tabs.Enabled` https://github.com/Textualize/textual/pull/3112 + ### Changed - grid-columns and grid-rows now accept an `auto` token to detect the optimal size https://github.com/Textualize/textual/pull/3107 diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 53a1b6afa8..41cec81736 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -375,3 +375,45 @@ def _watch_active(self, active: str) -> None: def tab_count(self) -> int: """Total number of tabs.""" return self.get_child_by_type(Tabs).tab_count + + def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: + """Disable the corresponding tab pane.""" + event.stop() + tab_id = event.tab.id + try: + self.query_one(f"TabPane#{tab_id}").disabled = True + except NoMatches: + return + + def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None: + """Enable the corresponding tab pane.""" + event.stop() + tab_id = event.tab.id + try: + self.query_one(f"TabPane#{tab_id}").disabled = False + except NoMatches: + return + + def disable_tab(self, tab_id: str) -> None: + """Disables the tab with the given ID. + + Args: + tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to disable. + + Raises: + Tabs.TabError: If there are any issues with the request. + """ + + self.query_one(Tabs).disable(tab_id) + + def enable_tab(self, tab_id: str) -> None: + """Enables the tab with the given ID. + + Args: + tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to enable. + + Raises: + Tabs.TabError: If there are any issues with the request. + """ + + self.query_one(Tabs).enable(tab_id) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index cbcfd7ddfb..ebbacefd48 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import ClassVar import rich.repr @@ -104,17 +105,32 @@ class Tab(Static): Tab.-active:hover { color: $text; } + Tab:disabled { + color: $text-disabled; + text-opacity: 50%; + } """ + @dataclass class Clicked(Message): """A tab was clicked.""" tab: Tab """The tab that was clicked.""" - def __init__(self, tab: Tab) -> None: - self.tab = tab - super().__init__() + @dataclass + class Disabled(Message): + """A tab was disabled.""" + + tab: Tab + """The tab that was disabled.""" + + @dataclass + class Enabled(Message): + """A tab was enabled.""" + + tab: Tab + """The tab that was enabled.""" def __init__( self, @@ -143,6 +159,10 @@ def _on_click(self): """Inform the message that the tab was clicked.""" self.post_message(self.Clicked(self)) + def _watch_disabled(self, disabled: bool) -> None: + """Notify the parent `Tabs` that a tab was enabled/disabled.""" + self.post_message(self.Disabled(self) if disabled else self.Enabled(self)) + class Tabs(Widget, can_focus=True): """A row of tabs.""" @@ -184,8 +204,8 @@ class Tabs(Widget, can_focus=True): class TabError(Exception): """Exception raised when there is an error relating to tabs.""" - class TabActivated(Message): - """Sent when a new tab is activated.""" + class TabMessage(Message): + """Parent class for all messages that have to do with a specific tab.""" ALLOW_SELECTOR_MATCH = {"tab"} """Additional message attributes that can be used with the [`on` decorator][textual.on].""" @@ -195,20 +215,20 @@ def __init__(self, tabs: Tabs, tab: Tab) -> None: Args: tabs: The Tabs widget. - tab: The tab that was activated. + tab: The tab that is the object of this message. """ self.tabs: Tabs = tabs """The tabs widget containing the tab.""" self.tab: Tab = tab - """The tab that was activated.""" + """The tab that is the object of this message.""" super().__init__() @property def control(self) -> Tabs: - """The tabs widget containing the tab that was activated. + """The tabs widget containing the tab that is the object of this message. - This is an alias for [`TabActivated.tabs`][textual.widgets.Tabs.TabActivated.tabs] - which is used by the [`on`][textual.on] decorator. + This is an alias for the attribute `tabs` and is used by the + [`on`][textual.on] decorator. """ return self.tabs @@ -216,6 +236,15 @@ def __rich_repr__(self) -> rich.repr.Result: yield self.tabs yield self.tab + class TabActivated(TabMessage): + """Sent when a new tab is activated.""" + + class TabDisabled(TabMessage): + """Sent when a tab is disabled.""" + + class TabEnabled(TabMessage): + """Sent when a tab is enabled.""" + class Cleared(Message): """Sent when there are no active tabs.""" @@ -302,7 +331,12 @@ def tab_count(self) -> int: @property def _next_active(self) -> Tab | None: """Next tab to make active if the active tab is removed.""" - tabs = list(self.query("#tabs-list > Tab").results(Tab)) + active_tab = self.active_tab + tabs = [ + tab + for tab in self.query("#tabs-list > Tab").results(Tab) + if (not tab.disabled or tab is active_tab) + ] if self.active_tab is None: return None try: @@ -590,10 +624,68 @@ def _move_tab(self, direction: int) -> None: active_tab = self.active_tab if active_tab is None: return - tabs = list(self.query(Tab)) + tabs = list( + tab for tab in self.query(Tab) if (not tab.disabled or tab is active_tab) + ) if not tabs: return tab_count = len(tabs) new_tab_index = (tabs.index(active_tab) + direction) % tab_count self.active = tabs[new_tab_index].id or "" self._scroll_active_tab() + + def _on_tab_disabled(self, event: Tab.Disabled) -> None: + """Re-post the disabled message.""" + event.stop() + self.post_message(self.TabDisabled(self, event.tab)) + + def _on_tab_enabled(self, event: Tab.Enabled) -> None: + """Re-post the enabled message.""" + event.stop() + self.post_message(self.TabEnabled(self, event.tab)) + + def disable(self, tab_id: str) -> Tab: + """Disable the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + + try: + tab_to_disable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab) + except NoMatches: + raise self.TabError( + f"There is no tab with ID {tab_id!r} to disable." + ) from None + + tab_to_disable.disabled = True + return tab_to_disable + + def enable(self, tab_id: str) -> Tab: + """Enable the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + + try: + tab_to_enable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab) + except NoMatches: + raise self.TabError( + f"There is no tab with ID {tab_id!r} to enable." + ) from None + + tab_to_enable.disabled = False + return tab_to_enable From bf9ebb6466fa1adea325e3c6903f81b1a85f77d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Fri, 18 Aug 2023 16:54:41 +0100 Subject: [PATCH 3/6] Add ability to show/hide tabs. --- CHANGELOG.md | 3 + src/textual/widgets/_tabbed_content.py | 24 ++++++++ src/textual/widgets/_tabs.py | 83 +++++++++++++++++++++++--- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f5ddb452..cdd4b7f829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Methods `TabbedContent.disable_tab` and `TabbedContent.enable_tab` https://github.com/Textualize/textual/pull/3112 - Methods `Tabs.disable` and `Tabs.enable` https://github.com/Textualize/textual/pull/3112 - Messages `Tab.Disabled`, `Tab.Enabled`, `Tabs.TabDisabled` and `Tabs.Enabled` https://github.com/Textualize/textual/pull/3112 +- Methods `TabbedContent.hide_tab` and `TabbedContent.show_tab` https://github.com/Textualize/textual/pull/3112 +- Methods `Tabs.hide` and `Tabs.show` https://github.com/Textualize/textual/pull/3112 +- Messages `Tabs.TabHidden` and `Tabs.TabShown` https://github.com/Textualize/textual/pull/3112 ### Changed diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 41cec81736..e3d8d20c28 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -417,3 +417,27 @@ def enable_tab(self, tab_id: str) -> None: """ self.query_one(Tabs).enable(tab_id) + + def hide_tab(self, tab_id: str) -> None: + """Hides the tab with the given ID. + + Args: + tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to hide. + + Raises: + Tabs.TabError: If there are any issues with the request. + """ + + self.query_one(Tabs).hide(tab_id) + + def show_tab(self, tab_id: str) -> None: + """Shows the tab with the given ID. + + Args: + tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to show. + + Raises: + Tabs.TabError: If there are any issues with the request. + """ + + self.query_one(Tabs).show(tab_id) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index ebbacefd48..5c65786786 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -109,6 +109,9 @@ class Tab(Static): color: $text-disabled; text-opacity: 50%; } + Tab.-hidden { + display: none; + } """ @dataclass @@ -245,6 +248,12 @@ class TabDisabled(TabMessage): class TabEnabled(TabMessage): """Sent when a tab is enabled.""" + class TabHidden(TabMessage): + """Sent when a tab is hidden.""" + + class TabShown(TabMessage): + """Sent when a tab is shown.""" + class Cleared(Message): """Sent when there are no active tabs.""" @@ -329,14 +338,23 @@ def tab_count(self) -> int: return len(self.query("#tabs-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 = [ + def _potentially_active_tabs(self) -> list[Tab]: + """List of all tabs that could be active. + + This list is comprised of all tabs that are shown and enabled, + plus the active tab in case it is disabled. + """ + return [ tab for tab in self.query("#tabs-list > Tab").results(Tab) - if (not tab.disabled or tab is active_tab) + if ((not tab.disabled or tab is self.active_tab) and tab.display) ] + + @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 try: @@ -624,9 +642,7 @@ def _move_tab(self, direction: int) -> None: active_tab = self.active_tab if active_tab is None: return - tabs = list( - tab for tab in self.query(Tab) if (not tab.disabled or tab is active_tab) - ) + tabs = self._potentially_active_tabs if not tabs: return tab_count = len(tabs) @@ -689,3 +705,54 @@ def enable(self, tab_id: str) -> Tab: tab_to_enable.disabled = False return tab_to_enable + + def hide(self, tab_id: str) -> Tab: + """Hide the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + + try: + tab_to_hide = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab) + except NoMatches: + raise self.TabError(f"There is no tab with ID {tab_id!r} to hide.") + + if tab_to_hide.has_class("-active"): + next_tab = self._next_active + self.active = next_tab.id or "" if next_tab else "" + tab_to_hide.add_class("-hidden") + self.post_message(self.TabHidden(self, tab_to_hide)) + self.call_after_refresh(self._highlight_active) + return tab_to_hide + + def show(self, tab_id: str) -> Tab: + """Show the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + + try: + tab_to_show = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab) + except NoMatches: + raise self.TabError(f"There is no tab with ID {tab_id!r} to show.") + + tab_to_show.remove_class("-hidden") + self.post_message(self.TabShown(self, tab_to_show)) + if not self.active: + self._activate_tab(tab_to_show) + self.call_after_refresh(self._highlight_active) + return tab_to_show From ec198974eb373a1d8819f9cb28095d3d92c4d298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Fri, 18 Aug 2023 17:35:37 +0100 Subject: [PATCH 4/6] Add tests for enabling/disabling tabs. --- tests/test_tabbed_content.py | 117 +++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 882dd11523..0e0d3a4491 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -427,3 +427,120 @@ def on_tabbed_content_cleared(self) -> None: assert tabbed_content.tab_count == 0 assert tabbed_content.active == "" assert pilot.app.cleared == 1 + + +async def test_disabling_does_not_deactivate_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + def on_mount(self) -> None: + self.query_one("Tab#tab-1").disabled = True + + app = TabbedApp() + async with app.run_test(): + assert app.query_one(Tabs).active == "tab-1" + + +async def test_disabled_tab_cannot_be_clicked(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + def on_mount(self) -> None: + self.query_one("Tab#tab-2").disabled = True + + app = TabbedApp() + async with app.run_test() as pilot: + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-1" + + +async def test_disabling_via_tabbed_content(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + def on_mount(self) -> None: + self.query_one(TabbedContent).disable_tab("tab-2") + + app = TabbedApp() + async with app.run_test() as pilot: + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-1" + + +async def test_navigation_around_disabled_tabs(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + yield Label("tab-3") + yield Label("tab-4") + + def on_mount(self) -> None: + self.query_one("Tab#tab-1").disabled = True + self.query_one("Tab#tab-3").disabled = True + + app = TabbedApp() + async with app.run_test(): + tabs = app.query_one(Tabs) + assert tabs.active == "tab-1" + tabs.action_next_tab() + assert tabs.active == "tab-2" + tabs.action_next_tab() + assert tabs.active == "tab-4" + tabs.action_next_tab() + assert tabs.active == "tab-2" + tabs.action_previous_tab() + assert tabs.active == "tab-4" + + +async def test_reenabling_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + def on_mount(self) -> None: + self.query_one("Tab#tab-2").disabled = True + + def reenable(self) -> None: + app.query_one("Tab#tab-2").disabled = False + + app = TabbedApp() + async with app.run_test() as pilot: + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-1" + app.reenable() + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-2" + + +async def test_reenabling_via_tabbed_content(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + def on_mount(self) -> None: + self.query_one(TabbedContent).disable_tab("tab-2") + + def reenable(self) -> None: + self.query_one(TabbedContent).enable_tab("tab-2") + + app = TabbedApp() + async with app.run_test() as pilot: + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-1" + app.reenable() + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-2" From d5d8e812077e32c3e225208fca372e49553f3377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 21 Aug 2023 11:09:32 +0100 Subject: [PATCH 5/6] Add more tests for tab enabling/disabling/showing/hiding. --- .../__snapshots__/test_snapshots.ambr | 163 ++++++++++++++++++ .../snapshot_apps/modified_tabs.py | 27 +++ tests/snapshot_tests/test_snapshots.py | 5 + tests/test_tabbed_content.py | 152 ++++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/modified_tabs.py diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index e2c939ae7a..f68d2917b0 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -27317,6 +27317,169 @@ ''' # --- +# name: test_tabbed_content_with_modified_tabs + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FiddleWithTabsApp + + + + + + + + + + + Tab 1Tab 2Tab 4Tab 5 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Button  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_table_markup ''' diff --git a/tests/snapshot_tests/snapshot_apps/modified_tabs.py b/tests/snapshot_tests/snapshot_apps/modified_tabs.py new file mode 100644 index 0000000000..48bb66d567 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/modified_tabs.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, TabbedContent + + +class FiddleWithTabsApp(App[None]): + CSS = """ + TabPane:disabled { + background: red; + } + """ + + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Button() + yield Button() + yield Button() + yield Button() + yield Button() + + def on_mount(self) -> None: + self.query_one(TabbedContent).disable_tab(f"tab-1") + self.query_one(TabbedContent).disable_tab(f"tab-2") + self.query_one(TabbedContent).hide_tab(f"tab-3") + + +if __name__ == "__main__": + FiddleWithTabsApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index d82c91d782..5ad871c740 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -222,6 +222,11 @@ def test_tabbed_content(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py") +def test_tabbed_content_with_modified_tabs(snap_compare): + # Tabs enabled and hidden. + assert snap_compare(SNAPSHOT_APPS_DIR / "modified_tabs.py") + + def test_option_list_strings(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_strings.py") diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 0e0d3a4491..5eec2abcfb 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -544,3 +544,155 @@ def reenable(self) -> None: app.reenable() await pilot.click("Tab#tab-2") assert app.query_one(Tabs).active == "tab-2" + + +async def test_disabling_unknown_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test(): + with pytest.raises(Tabs.TabError): + app.query_one(TabbedContent).disable_tab("foo") + + +async def test_enabling_unknown_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test(): + with pytest.raises(Tabs.TabError): + app.query_one(TabbedContent).enable_tab("foo") + + +async def test_hide_unknown_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test(): + with pytest.raises(Tabs.TabError): + app.query_one(TabbedContent).hide_tab("foo") + + +async def test_show_unknown_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test(): + with pytest.raises(Tabs.TabError): + app.query_one(TabbedContent).show_tab("foo") + + +async def test_hide_show_messages(): + hide_msg = False + show_msg = False + + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + def on_tabs_tab_hidden(self) -> None: + nonlocal hide_msg + hide_msg = True + + def on_tabs_tab_shown(self) -> None: + nonlocal show_msg + show_msg = True + + app = TabbedApp() + async with app.run_test() as pilot: + app.query_one(TabbedContent).hide_tab("tab-1") + await pilot.pause() + assert hide_msg + app.query_one(TabbedContent).show_tab("tab-1") + await pilot.pause() + assert show_msg + + +async def test_hide_last_tab_means_no_tab_active(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + tabbed_content.hide_tab("tab-1") + await pilot.pause() + assert not tabbed_content.active + + +async def test_hiding_tabs_moves_active_to_next_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + yield Label("tab-3") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + tabbed_content.hide_tab("tab-1") + await pilot.pause() + assert tabbed_content.active == "tab-2" + tabbed_content.hide_tab("tab-2") + await pilot.pause() + assert tabbed_content.active == "tab-3" + + +async def test_showing_tabs_does_not_change_active_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + yield Label("tab-3") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + tabbed_content.hide_tab("tab-1") + tabbed_content.hide_tab("tab-2") + await pilot.pause() + # sanity check + assert tabbed_content.active == "tab-3" + + tabbed_content.show_tab("tab-1") + tabbed_content.show_tab("tab-2") + assert tabbed_content.active == "tab-3" + + +@pytest.mark.parametrize("tab_id", ["tab-1", "tab-2"]) +async def test_showing_first_tab_activates_tab(tab_id: str): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + tabbed_content.hide_tab("tab-1") + tabbed_content.hide_tab("tab-2") + await pilot.pause() + # sanity check + assert not tabbed_content.active + + tabbed_content.show_tab(tab_id) + await pilot.pause() + assert tabbed_content.active == tab_id From 8d83cd4abbc47163741950831927822373bef27f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 21 Aug 2023 17:06:32 +0100 Subject: [PATCH 6/6] Add control to messages. Related review comment: https://github.com/Textualize/textual/pull/3112#discussion_r1299951135 --- src/textual/widgets/_tabs.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 5c65786786..d98759ce70 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -115,25 +115,32 @@ class Tab(Static): """ @dataclass - class Clicked(Message): - """A tab was clicked.""" + class TabMessage(Message): + """Tab-related messages. + + These are mostly intended for internal use when interacting with `Tabs`. + """ tab: Tab - """The tab that was clicked.""" + """The tab that is the object of this message.""" - @dataclass - class Disabled(Message): - """A tab was disabled.""" + @property + def control(self) -> Tab: + """The tab that is the object of this message. - tab: Tab - """The tab that was disabled.""" + This is an alias for the attribute `tab` and is used by the + [`on`][textual.on] decorator. + """ + return self.tab - @dataclass - class Enabled(Message): - """A tab was enabled.""" + class Clicked(TabMessage): + """A tab was clicked.""" - tab: Tab - """The tab that was enabled.""" + class Disabled(TabMessage): + """A tab was disabled.""" + + class Enabled(TabMessage): + """A tab was enabled.""" def __init__( self,