ai-station/.venv/lib/python3.12/site-packages/textual/widgets/_tabbed_content.py

717 lines
23 KiB
Python
Raw Normal View History

2025-12-25 14:54:33 +00:00
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)