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:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5826989c7..48df1194d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149
 - Add undo and redo to TextArea https://github.com/Textualize/textual/pull/4124
 
+### Fixed
+
+- Fixed out-of-view `Tab` not being scrolled into view when `Tabs.active` is assigned https://github.com/Textualize/textual/issues/4150
+- Fixed `TabbedContent.TabActivate` not being posted when `TabbedContent.active` is assigned https://github.com/Textualize/textual/issues/4150
+
 ### Changed
 
 - Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124
@@ -31,7 +36,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030
 - Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878
 
-### Added 
+### Added
 
 - Added support for configuring dark and light themes for code in `Markdown` https://github.com/Textualize/textual/issues/3997
 
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
index e7ae3eb25..0edced54b 100644
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -511,13 +511,22 @@ class TabbedContent(Widget):
         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 @@ class TabbedContent(Widget):
         """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
index 0123e1768..276f7e58e 100644
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -583,6 +583,7 @@ class Tabs(Widget, can_focus=True):
             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
index 28765e8fc..6d7b0976e 100644
--- 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 @@ async def test_tabbed_content_initial():
 
 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 @@ async def test_tabbed_content_messages():
         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():