Repository: Textualize/textual
Pull request: Allow enabling/disabling tab via tab pane.
This fixes #3149.

Problem statement:
The `disabled` parameter for a `TabPane` should disable a tab
Consider this code:

```python
from textual.app import App, ComposeResult
from textual.widgets import TabbedContent, TabPane, Label

class DisableTabApp(App[None]):

    def compose(self) -> ComposeResult:
        with TabbedContent(id="top-level"):
            with TabPane("One", id="one", disabled=True):
                yield Label("This is tab one")
            with TabPane("Two", id="two"):
                yield Label("This is tab two")

if __name__ == "__main__":
    DisableTabApp().run()
```

Note the `disabled=True` on `TabPane`. Widgets in Textual will go into disabled mode when `disabled` is set to `True` when creating them. It would be reasonable for a developer to expect this to happen here too but currently it doesn't.

Hints from issue discussion:

Commit message: Tests/changelog.

Patch to review:
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/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
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:


Code review comments:
diff_hunk: @@ -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):
path: src/textual/widgets/_tabbed_content.py
text: @user2:
Might it not make sense to give this and `TabPane.Disabled` a common parent class (adds to the utility of `@user1` for example)?

@author:
I'm honestly not fussed either way. I'll add it if you ask for it, otherwise I'll just merge this PR. 🤷 

@user2:
It feels like it would be more in keeping with where we're slowly heading with multiple messages on a widget (where appropriate); but it sure wasn't a "must do won't approve or else". ;-)

@author:
Point taken. 9ef644c.