11from __future__ import annotations
22
3+ from asyncio import gather
34from itertools import zip_longest
5+ from typing import Generator
46
57from rich .repr import Result
68from rich .text import Text , TextType
79
810from ..app import ComposeResult
11+ from ..await_remove import AwaitRemove
12+ from ..css .query import NoMatches
913from ..message import Message
1014from ..reactive import reactive
11- from ..widget import Widget
15+ from ..widget import AwaitMount , Widget
1216from ._content_switcher import ContentSwitcher
1317from ._tabs import Tab , Tabs
1418
@@ -70,6 +74,25 @@ def __init__(
7074 )
7175
7276
77+ class AwaitTabbedContent :
78+ """An awaitable returned by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs."""
79+
80+ def __init__ (self , * awaitables : AwaitMount | AwaitRemove ) -> None :
81+ """Initialise the awaitable.
82+
83+ Args:
84+ *awaitables: The collection of awaitables to await.
85+ """
86+ super ().__init__ ()
87+ self ._awaitables = awaitables
88+
89+ def __await__ (self ) -> Generator [None , None , None ]:
90+ async def await_tabbed_content () -> None :
91+ await gather (* self ._awaitables )
92+
93+ return await_tabbed_content ().__await__ ()
94+
95+
7396class TabbedContent (Widget ):
7497 """A container with associated tabs to toggle content visibility."""
7598
@@ -117,6 +140,28 @@ def __rich_repr__(self) -> Result:
117140 yield self .tabbed_content
118141 yield self .tab
119142
143+ class Cleared (Message ):
144+ """Posted when there are no more tab panes."""
145+
146+ def __init__ (self , tabbed_content : TabbedContent ) -> None :
147+ """Initialize message.
148+
149+ Args:
150+ tabbed_content: The TabbedContent widget.
151+ """
152+ self .tabbed_content = tabbed_content
153+ """The `TabbedContent` widget that contains the tab activated."""
154+ super ().__init__ ()
155+
156+ @property
157+ def control (self ) -> TabbedContent :
158+ """The `TabbedContent` widget that was cleared of all tab panes.
159+
160+ This is an alias for [`Cleared.tabbed_content`][textual.widgets.TabbedContent.Cleared.tabbed_content]
161+ and is used by the [`on`][textual.on] decorator.
162+ """
163+ return self .tabbed_content
164+
120165 def __init__ (
121166 self ,
122167 * titles : TextType ,
@@ -151,37 +196,37 @@ def validate_active(self, active: str) -> str:
151196 Value of `active`.
152197
153198 Raises:
154- ValueError: If the active attribute is set to empty string.
199+ ValueError: If the active attribute is set to empty string when there are tabs available .
155200 """
156- if not active :
201+ if not active and self . get_child_by_type ( ContentSwitcher ). current :
157202 raise ValueError ("'active' tab must not be empty string." )
158203 return active
159204
160- def compose (self ) -> ComposeResult :
161- """Compose the tabbed content."""
205+ @staticmethod
206+ def _set_id (content : TabPane , new_id : int ) -> TabPane :
207+ """Set an id on the content, if not already present.
162208
163- def set_id (content : TabPane , new_id : str ) -> TabPane :
164- """Set an id on the content, if not already present.
209+ Args:
210+ content: a TabPane.
211+ new_id: Numeric ID to make the pane ID from.
165212
166- Args:
167- content: a TabPane.
168- new_id: New `is` attribute, if it is not already set.
213+ Returns:
214+ The same TabPane.
215+ """
216+ if content .id is None :
217+ content .id = f"tab-{ new_id } "
218+ return content
169219
170- Returns:
171- The same TabPane.
172- """
173- if content .id is None :
174- content .id = new_id
175- return content
220+ def compose (self ) -> ComposeResult :
221+ """Compose the tabbed content."""
176222
177223 # Wrap content in a `TabPane` if required.
178224 pane_content = [
179- (
180- set_id ( content , f"tab- { index } " )
225+ self . _set_id (
226+ content
181227 if isinstance (content , TabPane )
182- else TabPane (
183- title or self .render_str (f"Tab { index } " ), content , id = f"tab-{ index } "
184- )
228+ else TabPane (title or self .render_str (f"Tab { index } " ), content ),
229+ index ,
185230 )
186231 for index , (title , content ) in enumerate (
187232 zip_longest (self .titles , self ._tab_content ), 1
@@ -197,6 +242,99 @@ def set_id(content: TabPane, new_id: str) -> TabPane:
197242 with ContentSwitcher (initial = self ._initial or None ):
198243 yield from pane_content
199244
245+ def add_pane (
246+ self ,
247+ pane : TabPane ,
248+ * ,
249+ before : TabPane | str | None = None ,
250+ after : TabPane | str | None = None ,
251+ ) -> AwaitTabbedContent :
252+ """Add a new pane to the tabbed content.
253+
254+ Args:
255+ pane: The pane to add.
256+ before: Optional pane or pane ID to add the pane before.
257+ after: Optional pane or pane ID to add the pane after.
258+
259+ Returns:
260+ An awaitable object that waits for the pane to be added.
261+
262+ Raises:
263+ Tabs.TabError: If there is a problem with the addition request.
264+
265+ Note:
266+ Only one of `before` or `after` can be provided. If both are
267+ provided a `Tabs.TabError` will be raised.
268+ """
269+ if isinstance (before , TabPane ):
270+ before = before .id
271+ if isinstance (after , TabPane ):
272+ after = after .id
273+ tabs = self .get_child_by_type (Tabs )
274+ pane = self ._set_id (pane , tabs .tab_count + 1 )
275+ assert pane .id is not None
276+ pane .display = False
277+ return AwaitTabbedContent (
278+ tabs .add_tab (ContentTab (pane ._title , pane .id ), before = before , after = after ),
279+ self .get_child_by_type (ContentSwitcher ).mount (pane ),
280+ )
281+
282+ def remove_pane (self , pane_id : str ) -> AwaitTabbedContent :
283+ """Remove a given pane from the tabbed content.
284+
285+ Args:
286+ pane_id: The ID of the pane to remove.
287+
288+ Returns:
289+ An awaitable object that waits for the pane to be removed.
290+ """
291+ removals = [self .get_child_by_type (Tabs ).remove_tab (pane_id )]
292+ try :
293+ removals .append (
294+ self .get_child_by_type (ContentSwitcher )
295+ .get_child_by_id (pane_id )
296+ .remove ()
297+ )
298+ except NoMatches :
299+ # It's possible that the content itself may have gone away via
300+ # other means; so allow that to be a no-op.
301+ pass
302+ await_remove = AwaitTabbedContent (* removals )
303+
304+ async def _remove_content (cleared_message : TabbedContent .Cleared ) -> None :
305+ await await_remove
306+ if self .tab_count == 0 :
307+ self .post_message (cleared_message )
308+
309+ # Note that I create the message out here, rather than in
310+ # _remove_content, to ensure that the message's internal
311+ # understanding of who the sender is is correct.
312+ #
313+ # https://github.com/Textualize/textual/issues/2750
314+ self .call_after_refresh (_remove_content , self .Cleared (self ))
315+
316+ return await_remove
317+
318+ def clear_panes (self ) -> AwaitTabbedContent :
319+ """Remove all the panes in the tabbed content."""
320+ await_clear = AwaitTabbedContent (
321+ self .get_child_by_type (Tabs ).clear (),
322+ self .get_child_by_type (ContentSwitcher ).remove_children (),
323+ )
324+
325+ async def _clear_content (cleared_message : TabbedContent .Cleared ) -> None :
326+ await await_clear
327+ self .post_message (cleared_message )
328+
329+ # Note that I create the message out here, rather than in
330+ # _clear_content, to ensure that the message's internal
331+ # understanding of who the sender is is correct.
332+ #
333+ # https://github.com/Textualize/textual/issues/2750
334+ self .call_after_refresh (_clear_content , self .Cleared (self ))
335+
336+ return await_clear
337+
200338 def compose_add_child (self , widget : Widget ) -> None :
201339 """When using the context manager compose syntax, we want to attach nodes to the switcher.
202340
@@ -207,9 +345,10 @@ def compose_add_child(self, widget: Widget) -> None:
207345
208346 def _on_tabs_tab_activated (self , event : Tabs .TabActivated ) -> None :
209347 """User clicked a tab."""
348+ assert isinstance (event .tab , ContentTab )
349+ assert isinstance (event .tab .id , str )
210350 event .stop ()
211351 switcher = self .get_child_by_type (ContentSwitcher )
212- assert isinstance (event .tab , ContentTab )
213352 switcher .current = event .tab .id
214353 self .active = event .tab .id
215354 self .post_message (
@@ -222,9 +361,16 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
222361 def _on_tabs_cleared (self , event : Tabs .Cleared ) -> None :
223362 """All tabs were removed."""
224363 event .stop ()
364+ self .get_child_by_type (ContentSwitcher ).current = None
365+ self .active = ""
225366
226- def watch_active (self , active : str ) -> None :
367+ def _watch_active (self , active : str ) -> None :
227368 """Switch tabs when the active attributes changes."""
228369 with self .prevent (Tabs .TabActivated ):
229370 self .get_child_by_type (Tabs ).active = active
230371 self .get_child_by_type (ContentSwitcher ).current = active
372+
373+ @property
374+ def tab_count (self ) -> int :
375+ """Total number of tabs."""
376+ return self .get_child_by_type (Tabs ).tab_count
0 commit comments