from __future__ import annotations
from functools import partial
from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar
from ..colour import Colour
from ..components import ActionRow
from ..components import Container as ContainerComponent
from ..components import _component_factory
from ..enums import ComponentType, SeparatorSpacingSize
from ..utils import find
from .file import File
from .item import Item, ItemCallbackType
from .media_gallery import MediaGallery
from .section import Section
from .separator import Separator
from .text_display import TextDisplay
from .view import _walk_all_components
__all__ = ("Container",)
if TYPE_CHECKING:
from typing_extensions import Self
from ..types.components import ContainerComponent as ContainerComponentPayload
from .view import View
C = TypeVar("C", bound="Container")
V = TypeVar("V", bound="View", covariant=True)
[docs]
class Container(Item[V]):
"""Represents a UI Container.
The current items supported are as follows:
- :class:`discord.ui.Button`
- :class:`discord.ui.Select`
- :class:`discord.ui.Section`
- :class:`discord.ui.TextDisplay`
- :class:`discord.ui.MediaGallery`
- :class:`discord.ui.File`
- :class:`discord.ui.Separator`
.. versionadded:: 2.7
Parameters
----------
*items: :class:`Item`
The initial items in this container.
colour: Union[:class:`Colour`, :class:`int`]
The accent colour of the container. Aliased to ``color`` as well.
spoiler: Optional[:class:`bool`]
Whether this container has the spoiler overlay.
id: Optional[:class:`int`]
The container's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"items",
"colour",
"spoiler",
"id",
)
__container_children_items__: ClassVar[list[ItemCallbackType]] = []
def __init_subclass__(cls) -> None:
children: list[ItemCallbackType] = []
for base in reversed(cls.__mro__):
for member in base.__dict__.values():
if hasattr(member, "__discord_ui_model_type__"):
children.append(member)
cls.__container_children_items__ = children
def __init__(
self,
*items: Item,
colour: int | Colour | None = None,
color: int | Colour | None = None,
spoiler: bool = False,
id: int | None = None,
):
super().__init__()
self.items: list[Item] = []
self._underlying = ContainerComponent._raw_construct(
type=ComponentType.container,
id=id,
components=[],
accent_color=None,
spoiler=spoiler,
)
self.color = colour or color
for func in self.__container_children_items__:
item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__)
item.callback = partial(func, self, item)
self.add_item(item)
setattr(self, func.__name__, item)
for i in items:
self.add_item(i)
def _add_component_from_item(self, item: Item):
if item._underlying.is_v2():
self._underlying.components.append(item._underlying)
else:
found = False
for row in reversed(self._underlying.components):
if isinstance(row, ActionRow) and row.width + item.width <= 5: # If a valid ActionRow exists
row.children.append(item._underlying)
found = True
elif not isinstance(row, ActionRow):
# create new row if last component is v2
break
if not found:
row = ActionRow.with_components(item._underlying)
self._underlying.components.append(row)
def _set_components(self, items: list[Item]):
self._underlying.components.clear()
for item in items:
self._add_component_from_item(item)
[docs]
def add_item(self, item: Item) -> Self:
"""Adds an item to the container.
Parameters
----------
item: :class:`Item`
The item to add to the container.
Raises
------
TypeError
An :class:`Item` was not passed.
"""
if not isinstance(item, Item):
raise TypeError(f"expected Item not {item.__class__!r}")
item._view = self.view
if hasattr(item, "items"):
item.view = self
item.parent = self
self.items.append(item)
self._add_component_from_item(item)
return self
[docs]
def remove_item(self, item: Item | str | int) -> Self:
"""Removes an item from the container. If an int or str is passed, it will remove by Item :attr:`id` or ``custom_id`` respectively.
Parameters
----------
item: Union[:class:`Item`, :class:`int`, :class:`str`]
The item, ``id``, or item ``custom_id`` to remove from the container.
"""
if isinstance(item, (str, int)):
item = self.get_item(item)
try:
self.items.remove(item)
except ValueError:
pass
return self
[docs]
def get_item(self, id: str | int) -> Item | None:
"""Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`.
If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``.
This method will also search for nested items.
Parameters
----------
id: Union[:class:`str`, :class:`int`]
The id or custom_id of the item to get.
Returns
-------
Optional[:class:`Item`]
The item with the matching ``id`` or ``custom_id`` if it exists.
"""
if not id:
return None
attr = "id" if isinstance(id, int) else "custom_id"
child = find(lambda i: getattr(i, attr, None) == id, self.items)
if not child:
for i in self.items:
if hasattr(i, "get_item"):
if child := i.get_item(id):
return child
return child
[docs]
def add_section(
self,
*items: Item,
accessory: Item,
id: int | None = None,
) -> Self:
"""Adds a :class:`Section` to the container.
To append a pre-existing :class:`Section`, use the
:meth:`add_item` method, instead.
Parameters
----------
*items: :class:`Item`
The items contained in this section, up to 3.
Currently only supports :class:`~discord.ui.TextDisplay`.
accessory: Optional[:class:`Item`]
The section's accessory. This is displayed in the top right of the section.
Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`.
id: Optional[:class:`int`]
The section's ID.
"""
section = Section(*items, accessory=accessory, id=id)
return self.add_item(section)
[docs]
def add_text(self, content: str, id: int | None = None) -> Self:
"""Adds a :class:`TextDisplay` to the container.
Parameters
----------
content: :class:`str`
The content of the TextDisplay
id: Optiona[:class:`int`]
The text displays' ID.
"""
text = TextDisplay(content, id=id)
return self.add_item(text)
[docs]
def add_gallery(
self,
*items: Item,
id: int | None = None,
) -> Self:
"""Adds a :class:`MediaGallery` to the container.
To append a pre-existing :class:`MediaGallery`, use :meth:`add_item` instead.
Parameters
----------
*items: :class:`MediaGalleryItem`
The media this gallery contains.
id: Optiona[:class:`int`]
The gallery's ID.
"""
g = MediaGallery(*items, id=id)
return self.add_item(g)
[docs]
def add_file(self, url: str, spoiler: bool = False, id: int | None = None) -> Self:
"""Adds a :class:`TextDisplay` to the container.
Parameters
----------
url: :class:`str`
The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`.
spoiler: Optional[:class:`bool`]
Whether the file has the spoiler overlay. Defaults to ``False``.
id: Optiona[:class:`int`]
The file's ID.
"""
f = File(url, spoiler=spoiler, id=id)
return self.add_item(f)
[docs]
def add_separator(
self,
*,
divider: bool = True,
spacing: SeparatorSpacingSize = SeparatorSpacingSize.small,
id: int | None = None,
) -> Self:
"""Adds a :class:`Separator` to the container.
Parameters
----------
divider: :class:`bool`
Whether the separator is a divider. Defaults to ``True``.
spacing: :class:`~discord.SeparatorSpacingSize`
The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`.
id: Optional[:class:`int`]
The separator's ID.
"""
s = Separator(divider=divider, spacing=spacing, id=id)
return self.add_item(s)
[docs]
def copy_text(self) -> str:
"""Returns the text of all :class:`~discord.ui.TextDisplay` items in this container.
Equivalent to the `Copy Text` option on Discord clients.
"""
return "\n".join(t for i in self.items if (t := i.copy_text()))
@property
def spoiler(self) -> bool:
"""Whether the container has the spoiler overlay. Defaults to ``False``."""
return self._underlying.spoiler
@spoiler.setter
def spoiler(self, spoiler: bool) -> None:
self._underlying.spoiler = spoiler
@property
def colour(self) -> Colour | None:
return self._underlying.accent_color
@colour.setter
def colour(self, value: int | Colour | None): # type: ignore
if value is None or isinstance(value, Colour):
self._underlying.accent_color = value
elif isinstance(value, int):
self._underlying.accent_color = Colour(value=value)
else:
raise TypeError(f"Expected discord.Colour, int, or None but received {value.__class__.__name__} instead.")
color = colour
@Item.view.setter
def view(self, value):
self._view = value
for item in self.items:
item.parent = self
item._view = value
if hasattr(item, "items"):
item.view = value
@property
def type(self) -> ComponentType:
return self._underlying.type
@property
def width(self) -> int:
return 5
def is_dispatchable(self) -> bool:
return any(item.is_dispatchable() for item in self.items)
def is_persistent(self) -> bool:
return all(item.is_persistent() for item in self.items)
def refresh_component(self, component: ContainerComponent) -> None:
self._underlying = component
flattened = []
for c in component.components:
if isinstance(c, ActionRow):
flattened += c.children
else:
flattened.append(c)
for i, y in enumerate(flattened):
x = self.items[i]
x.refresh_component(y)
[docs]
def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self:
"""
Disables all buttons and select menus in the container.
Parameters
----------
exclusions: Optional[List[:class:`Item`]]
A list of items in `self.items` to not disable from the view.
"""
for item in self.walk_items():
if hasattr(item, "disabled") and (exclusions is None or item not in exclusions):
item.disabled = True
return self
[docs]
def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self:
"""
Enables all buttons and select menus in the container.
Parameters
----------
exclusions: Optional[List[:class:`Item`]]
A list of items in `self.items` to not enable from the view.
"""
for item in self.walk_items():
if hasattr(item, "disabled") and (exclusions is None or item not in exclusions):
item.disabled = False
return self
def walk_items(self) -> Iterator[Item]:
for item in self.items:
if hasattr(item, "walk_items"):
yield from item.walk_items()
else:
yield item
def to_component_dict(self) -> ContainerComponentPayload:
self._set_components(self.items)
return self._underlying.to_dict()
@classmethod
def from_component(cls: type[C], component: ContainerComponent) -> C:
from .view import _component_to_item # noqa: PLC0415
items = [_component_to_item(c) for c in _walk_all_components(component.components)]
return cls(
*items,
colour=component.accent_color,
spoiler=component.spoiler,
id=component.id,
)
callback = None