From 7cf1b48f5bc6fd9bbac30d96b46a46c6fd7f4d66 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: Wed, 23 Aug 2023 12:12:01 +0100 Subject: [PATCH 1/5] Allow enabling/disabling tab via tab pane. This allows one to use the 'disabled' attribute in tab panes to enable/disable a tab, which is particularly useful if you want to instantiate a tab that starts off as disabled, as seen in #3149. --- src/textual/widgets/_tabbed_content.py | 75 ++++++++++++++++++++++++-- src/textual/widgets/_tabs.py | 4 +- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index e3d8d20c28..f3806cf66f 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -1,6 +1,7 @@ from __future__ import annotations from asyncio import gather +from dataclasses import dataclass from itertools import zip_longest from typing import Generator @@ -26,14 +27,15 @@ class ContentTab(Tab): """A Tab with an associated content id.""" - def __init__(self, label: Text, content_id: str): + def __init__(self, label: Text, content_id: str, disabled: bool = False): """Initialize a ContentTab. Args: label: The label to be displayed within the tab. content_id: The id of the content associated with the tab. + disabled: Is the tab disabled? """ - super().__init__(label, id=content_id) + super().__init__(label, id=content_id, disabled=disabled) class TabPane(Widget): @@ -49,6 +51,38 @@ class TabPane(Widget): } """ + @dataclass + class Disabled(Message): + """Sent when a tab pane is disabled via its reactive `disabled`.""" + + tab_pane: TabPane + """The `TabPane` that was disabled.""" + + @property + def control(self) -> TabPane: + """The tab pane that is the object of this message. + + This is an alias for the attribute `tab_pane` and is used by the + [`on`][textual.on] decorator. + """ + return self.tab_pane + + @dataclass + class Enabled(Message): + """Sent when a tab pane is enabled via its reactive `disabled`.""" + + tab_pane: TabPane + """The `TabPane` that was enabled.""" + + @property + def control(self) -> TabPane: + """The tab pane that is the object of this message. + + This is an alias for the attribute `tab_pane` and is used by the + [`on`][textual.on] decorator. + """ + return self.tab_pane + def __init__( self, title: TextType, @@ -73,6 +107,10 @@ def __init__( *children, name=name, id=id, classes=classes, disabled=disabled ) + def _watch_disabled(self, disabled: bool) -> None: + """Notify the parent `TabbedContent` that a tab pane was enabled/disabled.""" + 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.""" @@ -235,7 +273,8 @@ def compose(self) -> ComposeResult: ] # Get a tab for each pane tabs = [ - ContentTab(content._title, content.id or "") for content in pane_content + ContentTab(content._title, content.id or "", disabled=content.disabled) + for content in pane_content ] # Yield the tabs yield Tabs(*tabs, active=self._initial or None) @@ -381,7 +420,20 @@ def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: event.stop() tab_id = event.tab.id try: - self.query_one(f"TabPane#{tab_id}").disabled = True + with self.prevent(TabPane.Disabled): + self.query_one(f"TabPane#{tab_id}").disabled = True + except NoMatches: + return + + def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None: + """Disable the corresponding tab.""" + event.stop() + tab_pane_id = event.tab_pane.id or "" + try: + with self.prevent(Tab.Disabled): + self.get_child_by_type(Tabs).query_one( + f"Tab#{tab_pane_id}" + ).disabled = True except NoMatches: return @@ -390,7 +442,20 @@ def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None: event.stop() tab_id = event.tab.id try: - self.query_one(f"TabPane#{tab_id}").disabled = False + with self.prevent(TabPane.Enabled): + self.query_one(f"TabPane#{tab_id}").disabled = False + except NoMatches: + return + + def _on_tab_pane_enabled(self, event: TabPane.Enabled) -> None: + """Enable the corresponding tab.""" + event.stop() + tab_pane_id = event.tab_pane.id or "" + try: + with self.prevent(Tab.Enabled): + self.get_child_by_type(Tabs).query_one( + f"Tab#{tab_pane_id}" + ).disabled = False except NoMatches: return diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index d98759ce70..ca03433644 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -148,6 +148,7 @@ def __init__( *, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: """Initialise a Tab. @@ -155,9 +156,10 @@ def __init__( label: The label to use in the tab. id: Optional ID for the widget. classes: Space separated list of class names. + disabled: Whether the tab is disabled or not. """ self.label = Text.from_markup(label) if isinstance(label, str) else label - super().__init__(id=id, classes=classes) + super().__init__(id=id, classes=classes, disabled=disabled) self.update(label) @property From a62302cf86e07524164aef52816a867763b2d6f9 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: Wed, 23 Aug 2023 12:12:50 +0100 Subject: [PATCH 2/5] Tests/changelog. --- CHANGELOG.md | 6 ++++ tests/test_tabbed_content.py | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2517c9de..e422433b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Added + +- Ability to enable/disable tabs via the reactive `disabled` in tab panes https://github.com/Textualize/textual/pull/3152 + ## [0.34.0] - 2023-08-22 ### Added diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 5eec2abcfb..af9437da27 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -475,6 +475,38 @@ def on_mount(self) -> None: assert app.query_one(Tabs).active == "tab-1" +async def test_disabling_via_tab_pane(): + 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("TabPane#tab-2").disabled = True + + app = TabbedApp() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-1" + + +async def test_creating_disabled_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("first"): + yield Label("hello") + with TabPane("second", disabled=True): + yield Label("world") + + 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: @@ -546,6 +578,29 @@ def reenable(self) -> None: assert app.query_one(Tabs).active == "tab-2" +async def test_reenabling_via_tab_pane(): + 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("TabPane#tab-2").disabled = True + + def reenable(self) -> None: + self.query_one("TabPane#tab-2").disabled = False + + app = TabbedApp() + async with app.run_test() as pilot: + await pilot.pause() + 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_disabling_unknown_tab(): class TabbedApp(App[None]): def compose(self) -> ComposeResult: From 7aa40601abb7cad7e0e35f1d9c854a46495ab949 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: Wed, 23 Aug 2023 14:26:06 +0100 Subject: [PATCH 3/5] Deactivate disabled tab. Related issues: #3148. --- src/textual/widgets/_tabs.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index ca03433644..686d59fbda 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -662,6 +662,9 @@ def _move_tab(self, direction: int) -> None: def _on_tab_disabled(self, event: Tab.Disabled) -> None: """Re-post the disabled message.""" event.stop() + if event.tab.has_class("-active"): + next_tab = self._next_active + self.active = next_tab.id or "" if next_tab else "" self.post_message(self.TabDisabled(self, event.tab)) def _on_tab_enabled(self, event: Tab.Enabled) -> None: @@ -689,6 +692,9 @@ def disable(self, tab_id: str) -> Tab: f"There is no tab with ID {tab_id!r} to disable." ) from None + if tab_to_disable.has_class("-active"): + next_tab = self._next_active + self.active = next_tab.id or "" if next_tab else "" tab_to_disable.disabled = True return tab_to_disable From 79f8ab05b779bac8d356c3280c2b029abc6d604a 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: Wed, 23 Aug 2023 14:31:34 +0100 Subject: [PATCH 4/5] Revert "Deactivate disabled tab." This reverts commit 7aa40601abb7cad7e0e35f1d9c854a46495ab949. --- src/textual/widgets/_tabs.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 686d59fbda..ca03433644 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -662,9 +662,6 @@ def _move_tab(self, direction: int) -> None: def _on_tab_disabled(self, event: Tab.Disabled) -> None: """Re-post the disabled message.""" event.stop() - if event.tab.has_class("-active"): - next_tab = self._next_active - self.active = next_tab.id or "" if next_tab else "" self.post_message(self.TabDisabled(self, event.tab)) def _on_tab_enabled(self, event: Tab.Enabled) -> None: @@ -692,9 +689,6 @@ def disable(self, tab_id: str) -> Tab: f"There is no tab with ID {tab_id!r} to disable." ) from None - if tab_to_disable.has_class("-active"): - next_tab = self._next_active - self.active = next_tab.id or "" if next_tab else "" tab_to_disable.disabled = True return tab_to_disable From 9ef644cd77444fe88454d01dafd6fe203071ad0e 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: Wed, 23 Aug 2023 14:43:38 +0100 Subject: [PATCH 5/5] Add base class for TabPane messages. Related review comment: https://github.com/Textualize/textual/pull/3152#discussion_r1302921959. --- src/textual/widgets/_tabbed_content.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 672fb126a1..3dafb5579f 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -52,11 +52,11 @@ class TabPane(Widget): """ @dataclass - class Disabled(Message): - """Sent when a tab pane is disabled via its reactive `disabled`.""" + class TabPaneMessage(Message): + """Base class for `TabPane` messages.""" tab_pane: TabPane - """The `TabPane` that was disabled.""" + """The `TabPane` that is he object of this message.""" @property def control(self) -> TabPane: @@ -68,20 +68,12 @@ def control(self) -> TabPane: return self.tab_pane @dataclass - class Enabled(Message): - """Sent when a tab pane is enabled via its reactive `disabled`.""" - - tab_pane: TabPane - """The `TabPane` that was enabled.""" - - @property - def control(self) -> TabPane: - """The tab pane that is the object of this message. + class Disabled(TabPaneMessage): + """Sent when a tab pane is disabled via its reactive `disabled`.""" - This is an alias for the attribute `tab_pane` and is used by the - [`on`][textual.on] decorator. - """ - return self.tab_pane + @dataclass + class Enabled(TabPaneMessage): + """Sent when a tab pane is enabled via its reactive `disabled`.""" def __init__( self,