Repository: Textualize/textual
Pull request: Update `TabbedContent` with methods to add, remove and clear tabs/panes
This PR extends `TabbedContent` with some new methods, and also adds a new message. The new methods are:

- `add_pane` to add a new `TabPane` to the widget
- `remove_pane` to remove an existing `TabPane` from the widget
- `clear_panes` to remove all existing panes from the widget
- `tab_count` (okay, actually a property) to give easy access to the underlying `Tabs.tab_count`

The new message is `TabbedContent.Cleared`, which, like with `Tabs`, is posted when the number of tabs in the widget falls to zero.

In the process of working on this, a small change has also been made to `Underline` and `Tabs` to counter the case where an animation of the underline is still taking place when the target tab has been removed.

---
Work on this was the cause for #2750 being noted.

Problem statement:
Add the ability to add tabs to TabbedContent
This has come up multiple times now. There should probably be a way to change the tabs in TabbedContent after it has been mounted. 

The ability to add more tabs has been a common request. Removal would make sense too.

Hints from issue discussion:

Commit message: Update the ChangeLog

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2604e7c19c..bb0e50471e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Class variable `CSS` to screens https://github.com/Textualize/textual/issues/2137
 - Class variable `CSS_PATH` to screens https://github.com/Textualize/textual/issues/2137
 - Added `cursor_foreground_priority` and `cursor_background_priority` to `DataTable` https://github.com/Textualize/textual/pull/2736
+- Added `TabbedContent.tab_count` https://github.com/Textualize/textual/pull/2751
+- Added `TabbedContnet.add_pane` https://github.com/Textualize/textual/pull/2751
+- Added `TabbedContent.remove_pane` https://github.com/Textualize/textual/pull/2751
+- Added `TabbedContent.clear_panes` https://github.com/Textualize/textual/pull/2751
+- Added `TabbedContent.Cleared` https://github.com/Textualize/textual/pull/2751
 
 ### Fixed
 
@@ -22,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Fixed issue where internal data of `OptionList` could be invalid for short window after `clear_options` https://github.com/Textualize/textual/pull/2754
 - Fixed `Tooltip` causing a `query_one` on a lone `Static` to fail https://github.com/Textualize/textual/issues/2723
 - Nested widgets wouldn't lose focus when parent is disabled https://github.com/Textualize/textual/issues/2772
+- Fixed the `Tabs` `Underline` highlight getting "lost" in some extreme situations https://github.com/Textualize/textual/pull/2751
 
 ### Changed
 
diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py
index 6f56773aa9..dd18b19c98 100644
--- a/src/textual/widgets/_content_switcher.py
+++ b/src/textual/widgets/_content_switcher.py
@@ -5,6 +5,7 @@
 from typing import Optional
 
 from ..containers import Container
+from ..css.query import NoMatches
 from ..events import Mount
 from ..reactive import reactive
 from ..widget import Widget
@@ -84,6 +85,9 @@ def watch_current(self, old: str | None, new: str | None) -> None:
         """
         with self.app.batch_update():
             if old:
-                self.get_child_by_id(old).display = False
+                try:
+                    self.get_child_by_id(old).display = False
+                except NoMatches:
+                    pass
             if new:
                 self.get_child_by_id(new).display = True
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
index 689ce8e800..1738120a2c 100644
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -1,14 +1,17 @@
 from __future__ import annotations
 
 from itertools import zip_longest
+from typing import Generator
 
 from rich.repr import Result
 from rich.text import Text, TextType
 
 from ..app import ComposeResult
+from ..await_remove import AwaitRemove
+from ..css.query import NoMatches
 from ..message import Message
 from ..reactive import reactive
-from ..widget import Widget
+from ..widget import AwaitMount, Widget
 from ._content_switcher import ContentSwitcher
 from ._tabs import Tab, Tabs
 
@@ -70,6 +73,26 @@ def __init__(
         )
 
 
+class AwaitTabbedContent:
+    """An awaitable return by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs."""
+
+    def __init__(self, *awaitables: AwaitMount | AwaitRemove) -> None:
+        """Initialise the awaitable.
+
+        Args:
+            *awaitables: The collection of awaitables to await.
+        """
+        super().__init__()
+        self._awaitables = awaitables
+
+    def __await__(self) -> Generator[None, None, None]:
+        async def await_tabbed_content() -> None:
+            for awaitable in self._awaitables:
+                await awaitable
+
+        return await_tabbed_content().__await__()
+
+
 class TabbedContent(Widget):
     """A container with associated tabs to toggle content visibility."""
 
@@ -117,6 +140,28 @@ def __rich_repr__(self) -> Result:
             yield self.tabbed_content
             yield self.tab
 
+    class Cleared(Message):
+        """Posted when there are no more tab panes."""
+
+        def __init__(self, tabbed_content: TabbedContent) -> None:
+            """Initialize message.
+
+            Args:
+                tabbed_content: The TabbedContent widget.
+            """
+            self.tabbed_content = tabbed_content
+            """The `TabbedContent` widget that contains the tab activated."""
+            super().__init__()
+
+        @property
+        def control(self) -> TabbedContent:
+            """The `TabbedContent` widget that was cleared of all tab panes.
+
+            This is an alias for [`Cleared.tabbed_content`][textual.widgets.TabbedContent.Cleared.tabbed_content]
+            and is used by the [`on`][textual.on] decorator.
+            """
+            return self.tabbed_content
+
     def __init__(
         self,
         *titles: TextType,
@@ -151,37 +196,37 @@ def validate_active(self, active: str) -> str:
             Value of `active`.
 
         Raises:
-            ValueError: If the active attribute is set to empty string.
+            ValueError: If the active attribute is set to empty string when there are tabs available.
         """
-        if not active:
-            raise ValueError("'active' tab must not be empty string.")
+        if not active and self.get_child_by_type(ContentSwitcher).current:
+            raise ValueError(f"'active' tab must not be empty string.")
         return active
 
-    def compose(self) -> ComposeResult:
-        """Compose the tabbed content."""
+    @staticmethod
+    def _set_id(content: TabPane, new_id: int) -> TabPane:
+        """Set an id on the content, if not already present.
 
-        def set_id(content: TabPane, new_id: str) -> TabPane:
-            """Set an id on the content, if not already present.
+        Args:
+            content: a TabPane.
+            new_id: Numeric ID to make the pane ID from.
 
-            Args:
-                content: a TabPane.
-                new_id: New `is` attribute, if it is not already set.
+        Returns:
+            The same TabPane.
+        """
+        if content.id is None:
+            content.id = f"tab-{new_id}"
+        return content
 
-            Returns:
-                The same TabPane.
-            """
-            if content.id is None:
-                content.id = new_id
-            return content
+    def compose(self) -> ComposeResult:
+        """Compose the tabbed content."""
 
         # Wrap content in a `TabPane` if required.
         pane_content = [
-            (
-                set_id(content, f"tab-{index}")
+            self._set_id(
+                content
                 if isinstance(content, TabPane)
-                else TabPane(
-                    title or self.render_str(f"Tab {index}"), content, id=f"tab-{index}"
-                )
+                else TabPane(title or self.render_str(f"Tab {index}"), content),
+                index,
             )
             for index, (title, content) in enumerate(
                 zip_longest(self.titles, self._tab_content), 1
@@ -197,6 +242,99 @@ def set_id(content: TabPane, new_id: str) -> TabPane:
         with ContentSwitcher(initial=self._initial or None):
             yield from pane_content
 
+    def add_pane(
+        self,
+        pane: TabPane,
+        *,
+        before: TabPane | str | None = None,
+        after: TabPane | str | None = None,
+    ) -> AwaitTabbedContent:
+        """Add a new pane to the tabbed content.
+
+        Args:
+            pane: The pane to add.
+            before: Optional pane or pane ID to add the pane before.
+            after: Optional pane or pane ID to add the pane after.
+
+        Returns:
+            An awaitable object that waits for the pane to be added.
+
+        Raises:
+            Tabs.TabError: If there is a problem with the addition request.
+
+        Note:
+            Only one of `before` or `after` can be provided. If both are
+            provided a `Tabs.TabError` will be raised.
+        """
+        if isinstance(before, TabPane):
+            before = before.id
+        if isinstance(after, TabPane):
+            after = after.id
+        tabs = self.get_child_by_type(Tabs)
+        pane = self._set_id(pane, tabs.tab_count + 1)
+        assert pane.id is not None
+        pane.display = False
+        return AwaitTabbedContent(
+            tabs.add_tab(ContentTab(pane._title, pane.id), before=before, after=after),
+            self.get_child_by_type(ContentSwitcher).mount(pane),
+        )
+
+    def remove_pane(self, pane_id: str) -> AwaitTabbedContent:
+        """Remove a given pane from the tabbed content.
+
+        Args:
+            pane_id: The ID of the pane to remove.
+
+        Returns:
+            An awaitable object that waits for the pane to be removed.
+        """
+        removals = [self.get_child_by_type(Tabs).remove_tab(pane_id)]
+        try:
+            removals.append(
+                self.get_child_by_type(ContentSwitcher)
+                .get_child_by_id(pane_id)
+                .remove()
+            )
+        except NoMatches:
+            # It's possible that the content itself may have gone away via
+            # other means; so allow that to be a no-op.
+            pass
+        await_remove = AwaitTabbedContent(*removals)
+
+        async def _remove_content(cleared_message: TabbedContent.Cleared) -> None:
+            await await_remove
+            if self.tab_count == 0:
+                self.post_message(cleared_message)
+
+        # Note that I create the message out here, rather than in
+        # _remove_content, to ensure that the message's internal
+        # understanding of who the sender is is correct.
+        #
+        # https://github.com/Textualize/textual/issues/2750
+        self.call_after_refresh(_remove_content, self.Cleared(self))
+
+        return await_remove
+
+    def clear_panes(self) -> AwaitTabbedContent:
+        """Remove all the panes in the tabbed content."""
+        await_clear = AwaitTabbedContent(
+            self.get_child_by_type(Tabs).clear(),
+            self.get_child_by_type(ContentSwitcher).remove_children(),
+        )
+
+        async def _clear_content(cleared_message: TabbedContent.Cleared) -> None:
+            await await_clear
+            self.post_message(cleared_message)
+
+        # Note that I create the message out here, rather than in
+        # _clear_content, to ensure that the message's internal
+        # understanding of who the sender is is correct.
+        #
+        # https://github.com/Textualize/textual/issues/2750
+        self.call_after_refresh(_clear_content, self.Cleared(self))
+
+        return await_clear
+
     def compose_add_child(self, widget: Widget) -> None:
         """When using the context manager compose syntax, we want to attach nodes to the switcher.
 
@@ -207,9 +345,10 @@ def compose_add_child(self, widget: Widget) -> None:
 
     def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
         """User clicked a tab."""
+        assert isinstance(event.tab, ContentTab)
+        assert isinstance(event.tab.id, str)
         event.stop()
         switcher = self.get_child_by_type(ContentSwitcher)
-        assert isinstance(event.tab, ContentTab)
         switcher.current = event.tab.id
         self.active = event.tab.id
         self.post_message(
@@ -222,9 +361,16 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
     def _on_tabs_cleared(self, event: Tabs.Cleared) -> None:
         """All tabs were removed."""
         event.stop()
+        self.get_child_by_type(ContentSwitcher).current = None
+        self.active = ""
 
-    def watch_active(self, active: str) -> None:
+    def _watch_active(self, active: str) -> None:
         """Switch tabs when the active attributes changes."""
         with self.prevent(Tabs.TabActivated):
             self.get_child_by_type(Tabs).active = active
             self.get_child_by_type(ContentSwitcher).current = active
+
+    @property
+    def tab_count(self) -> int:
+        """Total number of tabs."""
+        return self.get_child_by_type(Tabs).tab_count
diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py
index 223eafd025..b4f486e4a2 100644
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, ClassVar
+from typing import ClassVar
 
 import rich.repr
 from rich.style import Style
@@ -46,6 +46,8 @@ class Underline(Widget):
     """First cell in highlight."""
     highlight_end = reactive(0)
     """Last cell (inclusive) in highlight."""
+    show_highlight: reactive[bool] = reactive(True)
+    """Flag to indicate if a highlight should be shown at all."""
 
     class Clicked(Message):
         """Inform ancestors the underline was clicked."""
@@ -60,7 +62,11 @@ def __init__(self, offset: Offset) -> None:
     @property
     def _highlight_range(self) -> tuple[int, int]:
         """Highlighted range for underline bar."""
-        return (self.highlight_start, self.highlight_end)
+        return (
+            (self.highlight_start, self.highlight_end)
+            if self.show_highlight
+            else (0, 0)
+        )
 
     def render(self) -> RenderResult:
         """Render the bar."""
@@ -504,9 +510,11 @@ def _highlight_active(self, animate: bool = True) -> None:
         try:
             active_tab = self.query_one(f"#tabs-list > Tab.-active")
         except NoMatches:
+            underline.show_highlight = False
             underline.highlight_start = 0
             underline.highlight_end = 0
         else:
+            underline.show_highlight = True
             tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter)
             start, end = tab_region.column_span
             if animate:
diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py
index 216a6bf349..b7ed743eb7 100644
--- a/tests/test_tabbed_content.py
+++ b/tests/test_tabbed_content.py
@@ -1,6 +1,7 @@
 import pytest
 
 from textual.app import App, ComposeResult
+from textual.reactive import var
 from textual.widgets import Label, TabbedContent, TabPane
 
 
@@ -156,3 +157,104 @@ def on_tabbed_content_tab_activated(
         await pilot.pause()
         assert isinstance(app.message, TabbedContent.TabActivated)
         assert app.message.tab.label.plain == "bar"
+
+
+async def test_tabbed_content_add_after_from_empty():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            yield TabbedContent()
+
+    async with TabbedApp().run_test() as pilot:
+        tabbed_content = pilot.app.query_one(TabbedContent)
+        assert tabbed_content.active == ""
+        assert tabbed_content.tab_count == 0
+        await tabbed_content.add_pane(TabPane("Test 1", id="test-1"))
+        await pilot.pause()
+        assert tabbed_content.tab_count == 1
+        assert tabbed_content.active == "test-1"
+        await tabbed_content.add_pane(TabPane("Test 2", id="test-2"))
+        await pilot.pause()
+        assert tabbed_content.tab_count == 2
+        assert tabbed_content.active == "test-1"
+
+
+async def test_tabbed_content_add_after_from_composed():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield TabPane("Test 1", id="initial-1")
+                yield TabPane("Test 2", id="initial-2")
+                yield TabPane("Test 3", id="initial-3")
+
+    async with TabbedApp().run_test() as pilot:
+        tabbed_content = pilot.app.query_one(TabbedContent)
+        assert tabbed_content.tab_count == 3
+        assert tabbed_content.active == "initial-1"
+        await tabbed_content.add_pane(TabPane("Test 4", id="test-1"))
+        await pilot.pause()
+        assert tabbed_content.tab_count == 4
+        assert tabbed_content.active == "initial-1"
+        await tabbed_content.add_pane(TabPane("Test 5", id="test-2"))
+        await pilot.pause()
+        assert tabbed_content.tab_count == 5
+        assert tabbed_content.active == "initial-1"
+
+
+async def test_tabbed_content_removal():
+    class TabbedApp(App[None]):
+        cleared: var[int] = var(0)
+
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield TabPane("Test 1", id="initial-1")
+                yield TabPane("Test 2", id="initial-2")
+                yield TabPane("Test 3", id="initial-3")
+
+        def on_tabbed_content_cleared(self) -> None:
+            self.cleared += 1
+
+    async with TabbedApp().run_test() as pilot:
+        tabbed_content = pilot.app.query_one(TabbedContent)
+        assert tabbed_content.tab_count == 3
+        assert pilot.app.cleared == 0
+        assert tabbed_content.active == "initial-1"
+        await tabbed_content.remove_pane("initial-1")
+        await pilot.pause()
+        assert tabbed_content.tab_count == 2
+        assert pilot.app.cleared == 0
+        assert tabbed_content.active == "initial-2"
+        await tabbed_content.remove_pane("initial-2")
+        await pilot.pause()
+        assert tabbed_content.tab_count == 1
+        assert pilot.app.cleared == 0
+        assert tabbed_content.active == "initial-3"
+        await tabbed_content.remove_pane("initial-3")
+        await pilot.pause()
+        assert tabbed_content.tab_count == 0
+        assert pilot.app.cleared == 1
+        assert tabbed_content.active == ""
+
+
+async def test_tabbed_content_clear():
+    class TabbedApp(App[None]):
+        cleared: var[int] = var(0)
+
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield TabPane("Test 1", id="initial-1")
+                yield TabPane("Test 2", id="initial-2")
+                yield TabPane("Test 3", id="initial-3")
+
+        def on_tabbed_content_cleared(self) -> None:
+            self.cleared += 1
+
+    async with TabbedApp().run_test() as pilot:
+        tabbed_content = pilot.app.query_one(TabbedContent)
+        assert tabbed_content.tab_count == 3
+        assert tabbed_content.active == "initial-1"
+        assert pilot.app.cleared == 0
+        await tabbed_content.clear_panes()
+        await pilot.pause()
+        assert tabbed_content.tab_count == 0
+        assert tabbed_content.active == ""
+        assert pilot.app.cleared == 1


Code review comments:
diff_hunk: @@ -70,6 +73,26 @@ def __init__(
         )
 
 
+class AwaitTabbedContent:
+    """An awaitable return by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs."""
+
+    def __init__(self, *awaitables: AwaitMount | AwaitRemove) -> None:
+        """Initialise the awaitable.
+
+        Args:
+            *awaitables: The collection of awaitables to await.
+        """
+        super().__init__()
+        self._awaitables = awaitables
+
+    def __await__(self) -> Generator[None, None, None]:
+        async def await_tabbed_content() -> None:
+            for awaitable in self._awaitables:
path: src/textual/widgets/_tabbed_content.py
text: @user1:
Suspect we should use `asyncio.gather` here. Effect is the same, but it will be a little more efficient.

@author:
What sort of efficiency gain will we see here, and will it guarantee the order that they're awaited?

@user2:
I suspect that `asyncio.gather` _doesn't_ guarantee waiting order.

@author:
My understanding too; and in this case the aim *is* to await in the order given. (Moreover, the size of the list of things to await is never going to exceed 2 anyway)

@user1:
If these are `AwaitMount` and `AwaitRemove` objects, then they are running concurrently anyway. The order you await them won't change the order they are completed.

`gather` will await multiple things with less context switches. Doubt it is a tangible difference for 2, but it is the prefered way to await concurrent tasks.

@author:
> If these are AwaitMount and AwaitRemove objects, then they are running concurrently anyway. The order you await them won't change the order they are completed.

Ahh, interesting; this is a possible wrinkle to worry about here then. Might need to rethink this perhaps.

diff_hunk: @@ -151,37 +196,37 @@ def validate_active(self, active: str) -> str:
             Value of `active`.
 
         Raises:
-            ValueError: If the active attribute is set to empty string.
+            ValueError: If the active attribute is set to empty string when there are tabs available.
         """
-        if not active:
-            raise ValueError("'active' tab must not be empty string.")
+        if not active and self.get_child_by_type(ContentSwitcher).current:
+            raise ValueError(f"'active' tab must not be empty string.")
path: src/textual/widgets/_tabbed_content.py
text: @user1:
Superfluous f string?

@author:
Good catch, likely a slip of the thinger or a hangover from a version that said more.

diff_hunk: @@ -151,37 +196,37 @@ def validate_active(self, active: str) -> str:
             Value of `active`.
 
         Raises:
-            ValueError: If the active attribute is set to empty string.
+            ValueError: If the active attribute is set to empty string when there are tabs available.
         """
-        if not active:
-            raise ValueError("'active' tab must not be empty string.")
+        if not active and self.get_child_by_type(ContentSwitcher).current:
+            raise ValueError(f"'active' tab must not be empty string.")
path: src/textual/widgets/_tabbed_content.py
text: @user1:
```suggestion
            raise ValueError("'active' tab must not be empty string.")
```