from __future__ import annotations from asyncio import gather from dataclasses import dataclass from itertools import zip_longest from typing import Awaitable from rich.repr import Result from typing_extensions import Final from textual import events from textual.app import ComposeResult from textual.await_complete import AwaitComplete from textual.content import ContentText, ContentType from textual.css.query import NoMatches from textual.message import Message from textual.reactive import reactive from textual.widget import Widget from textual.widgets._content_switcher import ContentSwitcher from textual.widgets._tabs import Tab, Tabs __all__ = [ "ContentTab", "TabbedContent", "TabPane", ] class ContentTab(Tab): """A Tab with an associated content id.""" _PREFIX: Final[str] = "--content-tab-" """The prefix given to the tab IDs.""" @classmethod def add_prefix(cls, content_id: str) -> str: """Add the prefix to the given ID. Args: content_id: The ID to add the prefix to. Returns: The ID with the prefix added. """ return f"{cls._PREFIX}{content_id}" if content_id else content_id @classmethod def sans_prefix(cls, content_id: str) -> str: """Remove the prefix from the given ID. Args: content_id: The ID to remove the prefix from. Returns: The ID with the prefix removed. """ return ( content_id[len(cls._PREFIX) :] if content_id.startswith(cls._PREFIX) else content_id ) def __init__( self, label: ContentType, content_id: str, disabled: bool = False ) -> None: """Initialize a ContentTab. Args: label: The label to be displayed within the tab. content_id: The id of the content associated with the tab. disabled: Is the tab disabled? """ super().__init__(label, id=self.add_prefix(content_id), disabled=disabled) class ContentTabs(Tabs): """A Tabs which is associated with a TabbedContent.""" def __init__( self, *tabs: Tab | ContentText, active: str | None = None, tabbed_content: TabbedContent, ): """Initialize a ContentTabs. Args: *tabs: The child tabs. active: ID of the tab which should be active on start. tabbed_content: The associated TabbedContent instance. """ super().__init__( *tabs, active=active if active is None else ContentTab.add_prefix(active) ) self.tabbed_content = tabbed_content def get_content_tab(self, tab_id: str) -> ContentTab: """Get the `ContentTab` associated with the given `TabPane` ID. Args: tab_id: The ID of the tab to get. Returns: The tab associated with that ID. """ return self.query_one(f"#{ContentTab.add_prefix(tab_id)}", ContentTab) def disable(self, tab_id: str) -> Tab: """Disable the indicated tab. Args: tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable. Returns: The [`Tab`][textual.widgets.Tab] that was targeted. Raises: TabError: If there are any issues with the request. """ return super().disable(ContentTab.add_prefix(tab_id)) def enable(self, tab_id: str) -> Tab: """Enable the indicated tab. Args: tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable. Returns: The [`Tab`][textual.widgets.Tab] that was targeted. Raises: TabError: If there are any issues with the request. """ return super().enable(ContentTab.add_prefix(tab_id)) def hide(self, tab_id: str) -> Tab: """Hide the indicated tab. Args: tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide. Returns: The [`Tab`][textual.widgets.Tab] that was targeted. Raises: TabError: If there are any issues with the request. """ return super().hide(ContentTab.add_prefix(tab_id)) def show(self, tab_id: str) -> Tab: """Show the indicated tab. Args: tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show. Returns: The [`Tab`][textual.widgets.Tab] that was targeted. Raises: TabError: If there are any issues with the request. """ return super().show(ContentTab.add_prefix(tab_id)) class TabPane(Widget): """A container for switchable content, with additional title. This widget is intended to be used with [TabbedContent][textual.widgets.TabbedContent]. """ DEFAULT_CSS = """ TabPane { height: auto; } """ @dataclass class TabPaneMessage(Message): """Base class for `TabPane` messages.""" tab_pane: TabPane """The `TabPane` that is he object of this message.""" @property def control(self) -> TabPane: """The tab pane that is the object of this message. This is an alias for the attribute `tab_pane` and is used by the [`on`][textual.on] decorator. """ return self.tab_pane @dataclass class Disabled(TabPaneMessage): """Sent when a tab pane is disabled via its reactive `disabled`.""" @dataclass class Enabled(TabPaneMessage): """Sent when a tab pane is enabled via its reactive `disabled`.""" @dataclass class Focused(TabPaneMessage): """Sent when a child widget is focused.""" def __init__( self, title: ContentType, *children: Widget, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, ): """Initialize a TabPane. Args: title: Title of the TabPane (will be displayed in a tab label). *children: Widget to go inside the TabPane. name: Optional name for the TabPane. id: Optional ID for the TabPane. classes: Optional initial classes for the widget. disabled: Whether the TabPane is disabled or not. """ self._title = self.render_str(title) super().__init__( *children, name=name, id=id, classes=classes, disabled=disabled ) def _watch_disabled(self, disabled: bool) -> None: """Notify the parent `TabbedContent` that a tab pane was enabled/disabled.""" self.post_message(self.Disabled(self) if disabled else self.Enabled(self)) def _on_descendant_focus(self, event: events.DescendantFocus): """Tell TabbedContent parent something is focused in this pane.""" self.post_message(self.Focused(self)) class TabbedContent(Widget): """A container with associated tabs to toggle content visibility.""" ALLOW_MAXIMIZE = True DEFAULT_CSS = """ TabbedContent { height: auto; &> ContentTabs { dock: top; } } """ active: reactive[str] = reactive("", init=False) """The ID of the active tab, or empty string if none are active.""" class TabActivated(Message): """Posted when the active tab changes.""" ALLOW_SELECTOR_MATCH = {"pane"} """Additional message attributes that can be used with the [`on` decorator][textual.on].""" def __init__(self, tabbed_content: TabbedContent, tab: ContentTab) -> None: """Initialize message. Args: tabbed_content: The TabbedContent widget. tab: The Tab widget that was selected (contains the tab label). """ self.tabbed_content = tabbed_content """The `TabbedContent` widget that contains the tab activated.""" self.tab = tab """The `Tab` widget that was selected (contains the tab label).""" self.pane = tabbed_content.get_pane(tab) """The `TabPane` widget that was activated by selecting the tab.""" super().__init__() @property def control(self) -> TabbedContent: """The `TabbedContent` widget that contains the tab activated. This is an alias for [`TabActivated.tabbed_content`][textual.widgets.TabbedContent.TabActivated.tabbed_content] and is used by the [`on`][textual.on] decorator. """ return self.tabbed_content def __rich_repr__(self) -> Result: yield self.tabbed_content yield self.tab yield self.pane class Cleared(Message): """Posted when no tab pane is active. This can happen if all tab panes are removed or if the currently active tab pane is unset. """ 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: ContentType, initial: str = "", name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, ): """Initialize a TabbedContent widgets. Args: *titles: Positional argument will be used as title. initial: The id of the initial tab, or empty string to select the first tab. name: The name of the tabbed content. id: The ID of the tabbed content in the DOM. classes: The CSS classes of the tabbed content. disabled: Whether the tabbed content is disabled or not. """ self.titles = [self.render_str(title) for title in titles] self._tab_content: list[Widget] = [] self._initial = initial self._tab_counter = 0 super().__init__(name=name, id=id, classes=classes, disabled=disabled) @property def active_pane(self) -> TabPane | None: """The currently active pane, or `None` if no pane is active.""" active = self.active if not active: return None return self.get_pane(self.active) @staticmethod def _set_id(content: TabPane, new_id: int) -> 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. Returns: The same TabPane. """ if content.id is None: content.id = f"tab-{new_id}" return content def _generate_tab_id(self) -> int: """Auto generate a new tab id. Returns: An auto-incrementing integer. """ self._tab_counter += 1 return self._tab_counter def compose(self) -> ComposeResult: """Compose the tabbed content.""" # Wrap content in a `TabPane` if required. pane_content = [ self._set_id( ( content if isinstance(content, TabPane) else TabPane(title or self.render_str(f"Tab {index}"), content) ), self._generate_tab_id(), ) for index, (title, content) in enumerate( zip_longest(self.titles, self._tab_content), 1 ) ] # Get a tab for each pane tabs = [ ContentTab( content._title, content.id or "", disabled=content.disabled, ) for content in pane_content ] # Yield the tabs, and ensure they're linked to this TabbedContent. # It's important to associate the Tabs with the TabbedContent, so that this # TabbedContent can determine whether a message received from a Tabs instance # has been sent from this Tabs, or from a Tabs that may exist as a descendant # deeper in the DOM. yield ContentTabs(*tabs, active=self._initial or None, tabbed_content=self) # Yield the content switcher and panes 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, ) -> AwaitComplete: """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 optionally 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 an exception is raised. """ if isinstance(before, TabPane): before = before.id if isinstance(after, TabPane): after = after.id tabs = self.get_child_by_type(ContentTabs) pane = self._set_id(pane, self._generate_tab_id()) assert pane.id is not None pane.display = False return AwaitComplete( tabs.add_tab( ContentTab(pane._title, pane.id), before=before if before is None else ContentTab.add_prefix(before), after=after if after is None else ContentTab.add_prefix(after), ), self.get_child_by_type(ContentSwitcher).mount(pane), ) def remove_pane(self, pane_id: str) -> AwaitComplete: """Remove a given pane from the tabbed content. Args: pane_id: The ID of the pane to remove. Returns: An optionally awaitable object that waits for the pane to be removed and the Cleared message to be posted. """ removal_awaitables: list[Awaitable] = [ self.get_child_by_type(ContentTabs).remove_tab( ContentTab.add_prefix(pane_id) ) ] try: removal_awaitables.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 return AwaitComplete(*removal_awaitables) def clear_panes(self) -> AwaitComplete: """Remove all the panes in the tabbed content. Returns: An optionally awaitable object which waits for all panes to be removed and the Cleared message to be posted. """ await_clear = gather( self.get_child_by_type(ContentTabs).clear(), self.get_child_by_type(ContentSwitcher).remove_children(), ) async def _clear_content() -> None: await await_clear return AwaitComplete(_clear_content()) def compose_add_child(self, widget: Widget) -> None: """When using the context manager compose syntax, we want to attach nodes to the switcher. Args: widget: A Widget to add. """ self._tab_content.append(widget) def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: """User clicked a tab.""" 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) 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=self.get_child_by_type(ContentTabs).get_content_tab( self.active ), ) ) def _on_tab_pane_focused(self, event: TabPane.Focused) -> None: """One of the panes contains a widget that was programmatically focused.""" event.stop() if event.tab_pane.id is not None: self.active = event.tab_pane.id def _on_tabs_cleared(self, event: Tabs.Cleared) -> None: """Called when there are no active tabs. The tabs may have been cleared, or they may all be hidden.""" if self._is_associated_tabs(event.tabs): event.stop() self.get_child_by_type(ContentSwitcher).current = None self.active = "" def _is_associated_tabs(self, tabs: Tabs) -> bool: """Determine whether a tab is associated with this TabbedContent or not. A tab is "associated" with a `TabbedContent`, if it's one of the tabs that can be used to control it. These have a special type: `ContentTab`, and are linked back to this `TabbedContent` instance via a `tabbed_content` attribute. Args: tabs: The Tabs instance to check. Returns: True if the tab is associated with this `TabbedContent`. """ return isinstance(tabs, ContentTabs) and tabs.tabbed_content is self def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" with self.prevent(Tabs.TabActivated, Tabs.Cleared): self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(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), ) ) else: self.post_message( TabbedContent.Cleared(tabbed_content=self).set_sender(self) ) @property def tab_count(self) -> int: """Total number of tabs.""" return self.get_child_by_type(ContentTabs).tab_count def get_tab(self, pane_id: str | TabPane) -> Tab: """Get the `Tab` associated with the given ID or `TabPane`. Args: pane_id: The ID of the pane, or the pane itself. Returns: The Tab associated with the ID. Raises: ValueError: Raised if no ID was available. """ if target_id := (pane_id if isinstance(pane_id, str) else pane_id.id): return self.get_child_by_type(ContentTabs).get_content_tab(target_id) raise ValueError( "'pane_id' must be a non-empty string or a TabPane with an id." ) def get_pane(self, pane_id: str | ContentTab) -> TabPane: """Get the `TabPane` associated with the given ID or tab. Args: pane_id: The ID of the pane to get, or the Tab it is associated with. Returns: The `TabPane` associated with the ID or the given tab. Raises: ValueError: Raised if no ID was available. """ target_id: str | None = None if isinstance(pane_id, ContentTab): target_id = ( pane_id.id if pane_id.id is None else ContentTab.sans_prefix(pane_id.id) ) else: target_id = pane_id if target_id: pane = self.get_child_by_type(ContentSwitcher).get_child_by_id(target_id) assert isinstance(pane, TabPane) return pane raise ValueError( "'pane_id' must be a non-empty string or a ContentTab with an id." ) 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: with self.prevent(TabPane.Disabled): self.get_child_by_type(ContentSwitcher).get_child_by_id( ContentTab.sans_prefix(tab_id), expect_type=TabPane ).disabled = True except NoMatches: return def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None: """Disable the corresponding tab.""" event.stop() try: with self.prevent(Tab.Disabled): self.get_tab(event.tab_pane).disabled = True except NoMatches: return 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: with self.prevent(TabPane.Enabled): self.get_child_by_type(ContentSwitcher).get_child_by_id( ContentTab.sans_prefix(tab_id), expect_type=TabPane ).disabled = False except NoMatches: return def _on_tab_pane_enabled(self, event: TabPane.Enabled) -> None: """Enable the corresponding tab.""" event.stop() try: with self.prevent(Tab.Disabled): self.get_tab(event.tab_pane).disabled = False except NoMatches: return def disable_tab(self, tab_id: str) -> None: """Disables the tab with the given ID. Args: tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to disable. Raises: Tabs.TabError: If there are any issues with the request. """ self.get_child_by_type(ContentTabs).disable(tab_id) def enable_tab(self, tab_id: str) -> None: """Enables the tab with the given ID. Args: tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to enable. Raises: Tabs.TabError: If there are any issues with the request. """ self.get_child_by_type(ContentTabs).enable(tab_id) def hide_tab(self, tab_id: str) -> None: """Hides the tab with the given ID. Args: tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to hide. Raises: Tabs.TabError: If there are any issues with the request. """ self.get_child_by_type(ContentTabs).hide(tab_id) def show_tab(self, tab_id: str) -> None: """Shows the tab with the given ID. Args: tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to show. Raises: Tabs.TabError: If there are any issues with the request. """ self.get_child_by_type(ContentTabs).show(tab_id)