diff --git a/discord/components.py b/discord/components.py index 4b25bcb00..4e0196f7d 100644 --- a/discord/components.py +++ b/discord/components.py @@ -97,12 +97,19 @@ __all__ = ( class Component: """Represents a Discord Bot UI Kit Component. - Currently, the only components supported by Discord are: + The components supported by Discord are: - :class:`ActionRow` - :class:`Button` - :class:`SelectMenu` - :class:`TextInput` + - :class:`SectionComponent` + - :class:`TextDisplay` + - :class:`ThumbnailComponent` + - :class:`MediaGalleryComponent` + - :class:`FileComponent` + - :class:`SeparatorComponent` + - :class:`Container` This class is abstract and cannot be instantiated. @@ -705,11 +712,18 @@ class SectionComponent(Component): The section accessory. """ - def __init__(self, data: SectionComponentPayload) -> None: + __slots__ = ( + 'components', + 'accessory', + ) + + __repr_info__ = __slots__ + + def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionState]) -> None: self.components: List[SectionComponentType] = [] for component_data in data['components']: - component = _component_factory(component_data) + component = _component_factory(component_data, state) if component is not None: self.components.append(component) # type: ignore # should be the correct type here @@ -737,6 +751,11 @@ class ThumbnailComponent(Component): This inherits from :class:`Component`. + .. note:: + + The user constructible and usable type to create a thumbnail is :class:`discord.ui.Thumbnail` + not this one. + Attributes ---------- media: :class:`UnfurledAttachment` @@ -747,10 +766,12 @@ class ThumbnailComponent(Component): Whether this thumbnail is flagged as a spoiler. """ + __slots__ = () + def __init__( self, data: ThumbnailComponentPayload, - state: ConnectionState, + state: Optional[ConnectionState], ) -> None: self.media: UnfurledAttachment = UnfurledAttachment(data['media'], state) self.description: Optional[str] = data.get('description') @@ -932,13 +953,13 @@ class SeparatorComponent(Component): ---------- spacing: :class:`SeparatorSize` The spacing size of the separator. - divider: :class:`bool` - Whether this separator is a divider. + visible: :class:`bool` + Whether this separator is visible and shows a divider. """ __slots__ = ( 'spacing', - 'divider', + 'visible', ) def __init__( @@ -946,7 +967,7 @@ class SeparatorComponent(Component): data: SeparatorComponentPayload, ) -> None: self.spacing: SeparatorSize = try_enum(SeparatorSize, data.get('spacing', 1)) - self.divider: bool = data.get('divider', True) + self.visible: bool = data.get('divider', True) @property def type(self) -> Literal[ComponentType.separator]: @@ -955,7 +976,7 @@ class SeparatorComponent(Component): def to_dict(self) -> SeparatorComponentPayload: return { 'type': self.type.value, - 'divider': self.divider, + 'divider': self.visible, 'spacing': self.spacing.value, } @@ -1010,9 +1031,11 @@ def _component_factory( elif data['type'] in (3, 5, 6, 7, 8): return SelectMenu(data) elif data['type'] == 9: - return SectionComponent(data) + return SectionComponent(data, state) elif data['type'] == 10: return TextDisplay(data) + elif data['type'] == 11: + return ThumbnailComponent(data, state) elif data['type'] == 12: return MediaGalleryComponent(data, state) elif data['type'] == 13: diff --git a/discord/http.py b/discord/http.py index 6617efa27..58b501722 100644 --- a/discord/http.py +++ b/discord/http.py @@ -193,6 +193,12 @@ def handle_message_parameters( if view is not MISSING: if view is not None: payload['components'] = view.to_components() + + if view.has_components_v2(): + if flags is not MISSING: + flags.components_v2 = True + else: + flags = MessageFlags(components_v2=True) else: payload['components'] = [] diff --git a/discord/types/components.py b/discord/types/components.py index cffb67ead..a50cbdd1e 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -184,5 +184,6 @@ ContainerChildComponent = Union[ SectionComponent, ContainerComponent, SeparatorComponent, + ThumbnailComponent, ] Component = Union[ActionRowChildComponent, ContainerChildComponent] diff --git a/discord/ui/container.py b/discord/ui/container.py index 6792c188f..4bd68b724 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,23 +23,32 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar + +from .item import Item +from ..enums import ComponentType if TYPE_CHECKING: - from ..components import Component + from typing_extensions import Self + + from .view import View + from ..colour import Colour, Color + from ..components import Container as ContainerComponent + +V = TypeVar('V', bound='View', covariant=True) __all__ = ('Container',) -class Container: +class Container(Item[V]): """Represents a Components V2 Container. .. versionadded:: 2.6 Parameters ---------- - children: List[:class:`Item`] + children: List[:class:`Item`] The initial children of this container. accent_colour: Optional[:class:`~discord.Colour`] The colour of the container. Defaults to ``None``. @@ -48,29 +57,31 @@ class Container: spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults to ``False``. + timeout: Optional[:class:`float`] + The timeout to set to this container items. Defaults to ``180``. """ __discord_ui_container__ = True def __init__( self, - children: List[Component], + children: List[Item[Any]], *, accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, spoiler: bool = False, ) -> None: - self._children: List[Component] = children + self._children: List[Item[Any]] = children self.spoiler: bool = spoiler self._colour = accent_colour or accent_color @property - def children(self) -> List[Component]: - """List[:class:`~discord.Component`]: The children of this container.""" + def children(self) -> List[Item[Any]]: + """List[:class:`Item`]: The children of this container.""" return self._children.copy() @children.setter - def children(self, value: List[Component]) -> None: + def children(self, value: List[Item[Any]]) -> None: self._children = value @property @@ -84,3 +95,29 @@ class Container: accent_color = accent_colour """Optional[:class:`~discord.Color`]: The color of the container, or ``None``.""" + + @property + def type(self) -> Literal[ComponentType.container]: + return ComponentType.container + + def _is_v2(self) -> bool: + return True + + def to_component_dict(self) -> Dict[str, Any]: + base = { + 'type': self.type.value, + 'spoiler': self.spoiler, + 'components': [c.to_component_dict() for c in self._children] + } + if self._colour is not None: + base['accent_color'] = self._colour.value + return base + + @classmethod + def from_component(cls, component: ContainerComponent) -> Self: + from .view import _component_to_item + return cls( + children=[_component_to_item(c) for c in component.children], + accent_colour=component.accent_colour, + spoiler=component.spoiler, + ) diff --git a/discord/ui/item.py b/discord/ui/item.py index 1ee549283..2d2a3aaa6 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -80,6 +80,9 @@ class Item(Generic[V]): def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None: return None + def _is_v2(self) -> bool: + return False + @classmethod def from_component(cls: Type[I], component: Component) -> I: return cls() diff --git a/discord/ui/section.py b/discord/ui/section.py new file mode 100644 index 000000000..81a0e4ba4 --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,151 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, Union + +from .item import Item +from .text_display import TextDisplay +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import View + from ..components import SectionComponent + +V = TypeVar('V', bound='View', covariant=True) + + +class Section(Item[V]): + """Represents a UI section. + + .. versionadded:: 2.6 + + Parameters + ---------- + children: List[Union[:class:`str`, :class:`TextDisplay`]] + The text displays of this section. Up to 3. + accessory: Optional[:class:`Item`] + The section accessory. Defaults to ``None``. + """ + + __slots__ = ( + '_children', + 'accessory', + ) + + def __init__( + self, + children: List[Union[TextDisplay[Any], str]], + *, + accessory: Optional[Item[Any]] = None, + ) -> None: + if len(children) > 3: + raise ValueError('maximum number of children exceeded') + self._children: List[TextDisplay[Any]] = [ + c if isinstance(c, TextDisplay) else TextDisplay(c) for c in children + ] + self.accessory: Optional[Item[Any]] = accessory + + @property + def type(self) -> Literal[ComponentType.section]: + return ComponentType.section + + def _is_v2(self) -> bool: + return True + + def add_item(self, item: Union[str, TextDisplay[Any]]) -> Self: + """Adds an item to this section. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: Union[:class:`str`, :class:`TextDisplay`] + The text display to add. + + Raises + ------ + TypeError + A :class:`TextDisplay` was not passed. + ValueError + Maximum number of children has been exceeded (3). + """ + + if len(self._children) >= 3: + raise ValueError('maximum number of children exceeded') + + if not isinstance(item, (TextDisplay, str)): + raise TypeError(f'expected TextDisplay or str not {item.__class__.__name__}') + + self._children.append( + item if isinstance(item, TextDisplay) else TextDisplay(item), + ) + return self + + def remove_item(self, item: TextDisplay[Any]) -> Self: + """Removes an item from this section. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`TextDisplay` + The item to remove from the section. + """ + + try: + self._children.remove(item) + except ValueError: + pass + return self + + def clear_items(self) -> Self: + """Removes all the items from the section. + + This function returns the class instance to allow for fluent-style + chaining. + """ + self._children.clear() + return self + + @classmethod + def from_component(cls, component: SectionComponent) -> Self: + from .view import _component_to_item # >circular import< + return cls( + children=[_component_to_item(c) for c in component.components], + accessory=_component_to_item(component.accessory) if component.accessory else None, + ) + + def to_component_dict(self) -> Dict[str, Any]: + data = { + 'components': [c.to_component_dict() for c in self._children], + 'type': self.type.value, + } + if self.accessory: + data['accessory'] = self.accessory.to_component_dict() + return data diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py new file mode 100644 index 000000000..a984a1892 --- /dev/null +++ b/discord/ui/thumbnail.py @@ -0,0 +1,86 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, TypeVar + +from .item import Item +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import View + from ..components import ThumbnailComponent + +V = TypeVar('V', bound='View', covariant=True) + +__all__ = ( + 'Thumbnail', +) + +class Thumbnail(Item[V]): + """Represents a UI Thumbnail. + + .. versionadded:: 2.6 + + Parameters + ---------- + url: :class:`str` + The URL of the thumbnail. This can only point to a local attachment uploaded + within this item. URLs must match the ``attachment://file-name.extension`` + structure. + description: Optional[:class:`str`] + The description of this thumbnail. Defaults to ``None``. + spoiler: :class:`bool` + Whether to flag this thumbnail as a spoiler. Defaults to ``False``. + """ + + def __init__(self, url: str, *, description: Optional[str] = None, spoiler: bool = False) -> None: + self.url: str = url + self.description: Optional[str] = description + self.spoiler: bool = spoiler + + @property + def type(self) -> Literal[ComponentType.thumbnail]: + return ComponentType.thumbnail + + def _is_v2(self) -> bool: + return True + + def to_component_dict(self) -> Dict[str, Any]: + return { + 'type': self.type.value, + 'spoiler': self.spoiler, + 'media': {'url': self.url}, + 'description': self.description, + } + + @classmethod + def from_component(cls, component: ThumbnailComponent) -> Self: + return cls( + url=component.media.url, + description=component.description, + spoiler=component.spoiler, + ) diff --git a/discord/ui/view.py b/discord/ui/view.py index b6262cf22..4abac5116 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -41,7 +41,7 @@ from ..components import ( Button as ButtonComponent, SelectMenu as SelectComponent, SectionComponent, - TextDisplay, + TextDisplay as TextDisplayComponent, MediaGalleryComponent, FileComponent, SeparatorComponent, @@ -67,7 +67,6 @@ if TYPE_CHECKING: _log = logging.getLogger(__name__) -V2_COMPONENTS = (SectionComponent, TextDisplay, MediaGalleryComponent, FileComponent, SeparatorComponent) def _walk_all_components(components: List[Component]) -> Iterator[Component]: @@ -87,8 +86,7 @@ def _component_to_item(component: Component) -> Item: from .select import BaseSelect return BaseSelect.from_component(component) - if isinstance(component, V2_COMPONENTS): - return component + # TODO: convert V2 Components into Item's return Item.from_component(component) @@ -97,11 +95,13 @@ class _ViewWeights: # fmt: off __slots__ = ( 'weights', + 'max_weight', ) # fmt: on - def __init__(self, children: List[Item]): + def __init__(self, children: List[Item], container: bool): self.weights: List[int] = [0, 0, 0, 0, 0] + self.max_weight: int = 5 if container is False else 10 key = lambda i: sys.maxsize if i.row is None else i.row children = sorted(children, key=key) @@ -111,7 +111,7 @@ class _ViewWeights: def find_open_space(self, item: Item) -> int: for index, weight in enumerate(self.weights): - if weight + item.width <= 5: + if weight + item.width <= self.max_weight: return index raise ValueError('could not find open space for item') @@ -119,8 +119,8 @@ class _ViewWeights: def add_item(self, item: Item) -> None: if item.row is not None: total = self.weights[item.row] + item.width - if total > 5: - raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)') + if total > 10: + raise ValueError(f'item would not fit at row {item.row} ({total} > {self.max_weight} width)') self.weights[item.row] = total item._rendered_row = item.row else: @@ -195,7 +195,7 @@ class View: def __init__(self, *, timeout: Optional[float] = 180.0): self.__timeout = timeout self._children: List[Item[Self]] = self._init_children() - self.__weights = _ViewWeights(self._children) + self.__weights = _ViewWeights(self._children, self.__discord_ui_container__) self.id: str = os.urandom(16).hex() self._cache_key: Optional[int] = None self.__cancel_callback: Optional[Callable[[View], None]] = None @@ -228,23 +228,32 @@ class View: # or not, this simply is, whether a view has a component other than a url button return any(item.is_dispatchable() for item in self.children) - def to_components(self) -> List[Dict[str, Any]]: - def key(item: Item) -> int: - return item._rendered_row or 0 + def has_components_v2(self) -> bool: + return any(c._is_v2() for c in self.children) - children = sorted(self._children, key=key) + def to_components(self) -> List[Dict[str, Any]]: components: List[Dict[str, Any]] = [] - for _, group in groupby(children, key=key): - children = [item.to_component_dict() for item in group] - if not children: - continue + rows_index: Dict[int, int] = {} + # helper mapping to find action rows for items that are not + # v2 components - components.append( - { - 'type': 1, - 'components': children, - } - ) + for child in self._children: + if child._is_v2(): + components.append(child.to_component_dict()) + else: + row = child._rendered_row or 0 + index = rows_index.get(row) + + if index is not None: + components[index]['components'].append(child) + else: + components.append( + { + 'type': 1, + 'components': [child.to_component_dict()], + }, + ) + rows_index[row] = len(components) - 1 return components