Repository: Textualize/textual

Issue:
`TabbedContent` message mixup if `Tabs` inside a `TabPane`
I suspect there are a few issues here (or rather a few operations that might suffer from a similar problem) but I think this example perfectly illustrates the confusion that could arise. Consider the following code:

```python
from textual import on
from textual.app import App, ComposeResult
from textual.widgets import TabbedContent, Tabs, TabPane, Button

class TabbedContentMessageStealingApp(App[None]):

    CSS = """
    TabbedContent, TabPane {
        height: 1fr;
        width: 1fr;
    }
    """

    def compose(self) -> ComposeResult:
        with TabbedContent():
            with TabPane("Test"):
                yield Button("Disable", id="disable")
                yield Tabs("One", "Two", "Three", id="test-tabs")

    @on(Button.Pressed, "#disable")
    def disable_test(self) -> None:
        self.query_one("#test-tabs", Tabs).disable("tab-1")

if __name__ == "__main__":
    TabbedContentMessageStealingApp().run()
```

There is a `Tabs` within a `TabPane` within `TabbedContent`. There is a `Button` that, when pressed, will disable the first `Tab` of the `Tabs` that the developer has added. When pressed, you would expect the `Tab` with the title `One` to grey out and become unusable.

Instead what happens is *every* tab becomes disabled, as does the `Button` too:

![Screenshot 2024-02-28 at 11 33 14](https://github.com/Textualize/textual/assets/28237/8f96ce68-f691-47d8-b400-3ffbca7ef586)

This will, of course, be due to the message leaking up to the `TabbedContent` and its first tab getting disabled due to it having a similar ID.

Compare what happens with this code, where I give the `TabPane` an ID that avoids the clash.

```python
from textual import on
from textual.app import App, ComposeResult
from textual.widgets import TabbedContent, Tabs, TabPane, Button

class TabbedContentMessageStealingApp(App[None]):

    CSS = """
    TabbedContent, TabPane {
        height: 1fr;
        width: 1fr;
    }
    """

    def compose(self) -> ComposeResult:
        with TabbedContent():
            with TabPane("Test", id="avoid-me"):
                yield Button("Disable", id="disable")
                yield Tabs("One", "Two", "Three", id="test-tabs")

    @on(Button.Pressed, "#disable")
    def disable_test(self) -> None:
        self.query_one("#test-tabs", Tabs).disable("tab-1")

if __name__ == "__main__":
    TabbedContentMessageStealingApp().run()
```

![Screenshot 2024-02-28 at 11 35 19](https://github.com/Textualize/textual/assets/28237/e3adf431-b8a4-456c-9579-302324171b96)

It feels like something should be done to isolate the `TabbedContent` from any `Tabs` that might be in one or more of its panes.


Discussion:

Resolving patch:
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -610,6 +610,8 @@ def get_pane(self, pane_id: str | ContentTab) -> TabPane:
 
     def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
         """Disable the corresponding tab pane."""
+        if event.tabs.parent is not self:
+            return
         event.stop()
         tab_id = event.tab.id or ""
         try:
@@ -631,6 +633,8 @@ def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None:
 
     def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None:
         """Enable the corresponding tab pane."""
+        if event.tabs.parent is not self:
+            return
         event.stop()
         tab_id = event.tab.id or ""
         try:


Test patch:
diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py
--- a/tests/test_tabbed_content.py
+++ b/tests/test_tabbed_content.py
@@ -867,3 +867,26 @@ def compose(self) -> ComposeResult:
 
         assert inner_tabs.active_tab is None
         assert tabbed_content.active == "outer1"
+
+
+async def test_disabling_tab_within_tabbed_content_stays_isolated():
+    """Disabling a tab within a tab pane should not affect the TabbedContent."""
+
+    class TabsNestedInTabbedContent(App):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                with TabPane("TabbedContent", id="duplicate"):
+                    yield Tabs(
+                        Tab("Tab1", id="duplicate"),
+                        Tab("Tab2", id="stay-enabled"),
+                        id="test-tabs",
+                    )
+
+    app = TabsNestedInTabbedContent()
+    async with app.run_test() as pilot:
+        assert app.query_one("Tab#duplicate").disabled is False
+        assert app.query_one("TabPane#duplicate").disabled is False
+        app.query_one("#test-tabs", Tabs).disable("duplicate")
+        await pilot.pause()
+        assert app.query_one("Tab#duplicate").disabled is True
+        assert app.query_one("TabPane#duplicate").disabled is False