Repository: Textualize/textual

Issue:
TabbedContent active attribute doesn't post message TabActivated + doesn't arrange tabs
Hello,

I noticed that when using `active` reactive attribute, there is no message posted to `TabActivated`. I also noticed that if the tab bar is larger than the terminal, it doesn't correctly adjust the tabs to show what's around them as if you clicked on it.

Please see the MRE below to understand the issue:
```python
from textual import on
from textual.app import App
from textual.widgets import Button, TabbedContent, TabPane


class TabApp(App):
    def compose(self):
        with TabbedContent(id="tabbed_content"):
            for tab_id in range(50):
                yield TabPane(f"Tab_{tab_id}", id=f"tab_{tab_id}")

        yield Button("First Tab", id="first_tab")
        yield Button("Last Tab", id="last_tab")

    @on(TabbedContent.TabActivated, "#tabbed_content")
    def tab_changed(self, event: TabbedContent.TabActivated):
        self.notify(f"Tab changed {event.tab.id}")

    @on(Button.Pressed, "#first_tab")
    def on_first_tab(self):
        self.query_one("#tabbed_content", TabbedContent).active = "tab_0"

    @on(Button.Pressed, "#last_tab")
    def on_last_tab(self):
        self.query_one("#tabbed_content", TabbedContent).active = "tab_49"


app = TabApp()
app.run()
```

Thanks!



Discussion:
Related issue:

- #3869

I'm not suggesting this should be marked as duplicate, rather just trying to link this to the overarching issue. 
Using the following code we can see that the "scroll tab into view" problem stems from `Tabs`:

```python
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.widgets import TabbedContent, TabPane, Tabs, Tab, Label, Log, Rule

class TabbedContentActiveApp(App[None]):

    BINDINGS = [
        ("c", "jump_tabbed_content"),
        ("t", "jump_tabs")
    ]

    def compose(self) -> ComposeResult:
        with Vertical() as container:
            container.border_title = "TabbedContent version"
            with TabbedContent():
                for n in range(200):
                    with TabPane(f"Filler {n}", id=f"filler-{n}"):
                        yield Label(f"Filler tab {n}")
                with TabPane("PICK ME!", id="pick-me"):
                    yield Label("This is the tab we're after")
        with Vertical() as container:
            container.border_title = "Tabs version"
            yield Tabs(
                *(
                    tuple(Tab(f"Filler {n}", id=f"filler-{n}") for n in range(200)) +
                    (Tab("PICK ME!", id="pick-me"),)
                )
            )
        yield Rule()
        yield Log()

    @on(TabbedContent.TabActivated)
    @on(Tabs.TabActivated)
    def log_messages(self, event: TabbedContent.TabActivated | Tabs.TabActivated) -> None:
        self.query_one(Log).write_line(f"{event!r}")

    def action_jump_tabbed_content(self) -> None:
        self.query_one(TabbedContent).active = "pick-me"

    def action_jump_tabs(self) -> None:
        self.query_one("Vertical > Tabs", Tabs).active = "pick-me"

if __name__ == "__main__":
    TabbedContentActiveApp().run()
```

We can also see that `Tabs` *does* post the relevant message when `active` is set, so most likely `TabbedContent` isn't handling that situation correctly.

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
@@ -511,13 +511,22 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
         if self._is_associated_tabs(event.tabs):
             # The message is relevant, so consume it and update state accordingly.
             event.stop()
+            assert event.tab.id is not None
             switcher = self.get_child_by_type(ContentSwitcher)
             switcher.current = ContentTab.sans_prefix(event.tab.id)
-            self.active = ContentTab.sans_prefix(event.tab.id)
+            with self.prevent(self.TabActivated):
+                # We prevent TabbedContent.TabActivated because it is also
+                # posted from the watcher for active, we're also about to
+                # post it below too, which is valid as here we're reacting
+                # to what the Tabs are doing. This ensures we don't get
+                # doubled-up messages.
+                self.active = ContentTab.sans_prefix(event.tab.id)
             self.post_message(
                 TabbedContent.TabActivated(
                     tabbed_content=self,
-                    tab=event.tab,
+                    tab=self.get_child_by_type(ContentTabs).get_content_tab(
+                        self.active
+                    ),
                 )
             )
 
@@ -548,7 +557,14 @@ def _watch_active(self, active: str) -> None:
         """Switch tabs when the active attributes changes."""
         with self.prevent(Tabs.TabActivated):
             self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active)
-            self.get_child_by_type(ContentSwitcher).current = active
+        self.get_child_by_type(ContentSwitcher).current = active
+        if active:
+            self.post_message(
+                TabbedContent.TabActivated(
+                    tabbed_content=self,
+                    tab=self.get_child_by_type(ContentTabs).get_content_tab(active),
+                )
+            )
 
     @property
     def tab_count(self) -> int:
diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -583,6 +583,7 @@ def watch_active(self, previously_active: str, active: str) -> None:
             self.query("#tabs-list > Tab.-active").remove_class("-active")
             active_tab.add_class("-active")
             self._highlight_active(animate=previously_active != "")
+            self._scroll_active_tab()
             self.post_message(self.TabActivated(self, active_tab))
         else:
             underline = self.query_one(Underline)


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
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import pytest
 
 from textual.app import App, ComposeResult
@@ -137,13 +139,12 @@ def compose(self) -> ComposeResult:
 
 async def test_tabbed_content_messages():
     class TabbedApp(App):
-        message = None
+        activation_history: list[Tab] = []
 
         def compose(self) -> ComposeResult:
-            with TabbedContent(initial="bar"):
+            with TabbedContent():
                 with TabPane("foo", id="foo"):
                     yield Label("Foo", id="foo-label")
-
                 with TabPane("bar", id="bar"):
                     yield Label("Bar", id="bar-label")
                 with TabPane("baz", id="baz"):
@@ -152,15 +153,19 @@ def compose(self) -> ComposeResult:
         def on_tabbed_content_tab_activated(
             self, event: TabbedContent.TabActivated
         ) -> None:
-            self.message = event
+            self.activation_history.append(event.tab)
 
     app = TabbedApp()
     async with app.run_test() as pilot:
         tabbed_content = app.query_one(TabbedContent)
         tabbed_content.active = "bar"
         await pilot.pause()
-        assert isinstance(app.message, TabbedContent.TabActivated)
-        assert app.message.tab.label.plain == "bar"
+        assert app.activation_history == [
+            # foo was originally activated.
+            app.query_one(TabbedContent).get_tab("foo"),
+            # then we did bar "by hand"
+            app.query_one(TabbedContent).get_tab("bar"),
+        ]
 
 
 async def test_tabbed_content_add_later_from_empty():