diff --git a/discord/abc.py b/discord/abc.py index 70531fb20..1380b3048 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1389,6 +1389,7 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1410,6 +1411,7 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1431,6 +1433,7 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1452,6 +1455,7 @@ class Messageable: reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., + views: Sequence[View] = ..., suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., @@ -1474,6 +1478,7 @@ class Messageable: reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, view: Optional[View] = None, + views: Optional[Sequence[View]] = None, suppress_embeds: bool = False, silent: bool = False, poll: Optional[Poll] = None, @@ -1550,6 +1555,10 @@ class Messageable: A Discord UI View to add to the message. .. versionadded:: 2.0 + views: Sequence[:class:`discord.ui.View`] + A sequence of Discord UI Views to add to the message. + + .. versionadded:: 2.6 stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. @@ -1580,7 +1589,8 @@ class Messageable: You specified both ``file`` and ``files``, or you specified both ``embed`` and ``embeds``, or the ``reference`` object is not a :class:`~discord.Message`, - :class:`~discord.MessageReference` or :class:`~discord.PartialMessage`. + :class:`~discord.MessageReference` or :class:`~discord.PartialMessage`, + or you specified both ``view`` and ``views``. Returns --------- @@ -1635,6 +1645,7 @@ class Messageable: mention_author=mention_author, stickers=sticker_ids, view=view, + views=views if views is not None else MISSING, flags=flags, poll=poll, ) as params: diff --git a/discord/http.py b/discord/http.py index d8eedeb2e..c6e4d1377 100644 --- a/discord/http.py +++ b/discord/http.py @@ -192,6 +192,8 @@ def handle_message_parameters( if view is not MISSING: if view is not None: + if getattr(view, '__discord_ui_container__', False): + raise TypeError('Containers must be wrapped around Views') payload['components'] = view.to_components() if view.has_components_v2(): diff --git a/discord/ui/button.py b/discord/ui/button.py index 43bd3a8b0..b4df36aed 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -73,10 +73,11 @@ class Button(Item[V]): The emoji of the button, if available. row: Optional[:class:`int`] The relative row this button belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). + rows in a :class:`View`, but up to 10 on a :class:`Container`. By default, + items are arranged automatically into those rows. If you'd like to control the + relative positioning of the row then passing an index is advised. For example, + row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 4 or 9 (i.e. zero indexed). sku_id: Optional[:class:`int`] The SKU ID this button sends you to. Can't be combined with ``url``, ``label``, ``emoji`` nor ``custom_id``. @@ -304,10 +305,11 @@ def button( or a full :class:`.Emoji`. row: Optional[:class:`int`] The relative row this button belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). + rows in a :class:`View`, but up to 10 on a :class:`Container`. By default, + items are arranged automatically into those rows. If you'd like to control the + relative positioning of the row then passing an index is advised. For example, + row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 4 or 9 (i.e. zero indexed). """ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: diff --git a/discord/ui/container.py b/discord/ui/container.py index a2ca83a25..a98b0d965 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,11 +23,11 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -import sys -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, TypeVar from .item import Item from .view import View, _component_to_item +from .dynamic import DynamicItem from ..enums import ComponentType if TYPE_CHECKING: @@ -48,7 +48,7 @@ class Container(View, Item[V]): Parameters ---------- - children: List[Union[:class:`Item`, :class:`View`]] + children: List[:class:`Item`] The initial children or :class:`View`s of this container. Can have up to 10 items. accent_colour: Optional[:class:`~discord.Colour`] @@ -58,34 +58,47 @@ class Container(View, Item[V]): spoiler: :class:`bool` Whether to flag this container as a spoiler. Defaults to ``False``. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. """ __discord_ui_container__ = True def __init__( self, - children: List[Union[Item[Any], View]], + children: List[Item[Any]], *, accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, spoiler: bool = False, timeout: Optional[float] = 180, + row: Optional[int] = None, ) -> None: - if len(children) > 10: + super().__init__(timeout=timeout) + if len(children) + len(self._children) > 10: raise ValueError('maximum number of components exceeded') - self._children: List[Union[Item[Any], View]] = children + self._children.extend(children) self.spoiler: bool = spoiler self._colour = accent_colour or accent_color - super().__init__(timeout=timeout) + self._view: Optional[V] = None + self._row: Optional[int] = None + self._rendered_row: Optional[int] = None + self.row: Optional[int] = row + + def _init_children(self) -> List[Item[Self]]: + if self.__weights.max_weight != 10: + self.__weights.max_weight = 10 + return super()._init_children() @property - def children(self) -> List[Union[Item[Any], View]]: + def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The children of this container.""" return self._children.copy() @children.setter - def children(self, value: List[Union[Item[Any], View]]) -> None: + def children(self, value: List[Item[Any]]) -> None: self._children = value @property @@ -105,20 +118,38 @@ class Container(View, Item[V]): return ComponentType.container @property - def _views(self) -> List[View]: - return [c for c in self._children if isinstance(c, View)] + def width(self): + return 5 def _is_v2(self) -> bool: return True - def to_components(self) -> List[Dict[str, Any]]: + def is_dispatchable(self) -> bool: + return any(c.is_dispatchable() for c in self.children) + + def to_component_dict(self) -> Dict[str, Any]: components = super().to_components() - return [{ + return { 'type': self.type.value, 'accent_color': self._colour.value if self._colour else None, 'spoiler': self.spoiler, 'components': components, - }] + } + + def _update_store_data( + self, + dispatch_info: Dict[Tuple[int, str], Item[Any]], + dynamic_items: Dict[Any, Type[DynamicItem]], + ) -> bool: + is_fully_dynamic = True + for item in self._children: + if isinstance(item, DynamicItem): + pattern = item.__discord_ui_compiled_template__ + dynamic_items[pattern] = item.__class__ + elif item.is_dispatchable(): + dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + is_fully_dynamic = False + return is_fully_dynamic @classmethod def from_component(cls, component: ContainerComponent) -> Self: diff --git a/discord/ui/section.py b/discord/ui/section.py index 5176d761b..0012d0118 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -62,6 +62,7 @@ class Section(Item[V]): *, accessory: Optional[Item[Any]] = None, ) -> None: + super().__init__() if len(children) > 3: raise ValueError('maximum number of children exceeded') self._children: List[Item[Any]] = [ @@ -73,6 +74,10 @@ class Section(Item[V]): def type(self) -> Literal[ComponentType.section]: return ComponentType.section + @property + def width(self): + return 5 + def _is_v2(self) -> bool: return True diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py new file mode 100644 index 000000000..0daff9c89 --- /dev/null +++ b/discord/ui/text_display.py @@ -0,0 +1,71 @@ +""" +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, Literal, TypeVar + +from .item import Item +from ..components import TextDisplay as TextDisplayComponent +from ..enums import ComponentType + +if TYPE_CHECKING: + from .view import View + +V = TypeVar('V', bound='View', covariant=True) + +__all__ = ('TextDisplay',) + + +class TextDisplay(Item[V]): + """Represents a UI text display. + + .. versionadded:: 2.6 + + Parameters + ---------- + content: :class:`str` + The content of this text display. + """ + + def __init__(self, content: str) -> None: + super().__init__() + self.content: str = content + + self._underlying = TextDisplayComponent._raw_construct( + content=content, + ) + + def to_component_dict(self): + return self._underlying.to_dict() + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.text_display]: + return self._underlying.type + + def _is_v2(self) -> bool: + return True diff --git a/discord/ui/view.py b/discord/ui/view.py index 19bc3f33b..4afcd9fad 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -95,11 +95,13 @@ class _ViewWeights: # fmt: off __slots__ = ( 'weights', + 'max_weight', ) # fmt: on def __init__(self, children: List[Item]): self.weights: List[int] = [0, 0, 0, 0, 0] + self.max_weight: int = 5 key = lambda i: sys.maxsize if i.row is None else i.row children = sorted(children, key=key) @@ -107,26 +109,21 @@ class _ViewWeights: for item in group: self.add_item(item) - def find_open_space(self, item: Union[Item, View]) -> int: + def find_open_space(self, item: Item) -> int: for index, weight in enumerate(self.weights): if weight + item.width <= 5: return index raise ValueError('could not find open space for item') - def add_item(self, item: Union[Item, View]) -> None: - if hasattr(item, '__discord_ui_container__') and item.__discord_ui_container__ is True: - raise TypeError( - 'containers cannot be added to views' - ) - + def add_item(self, item: Item) -> None: if item._is_v2() and not self.v2_weights(): # v2 components allow up to 10 rows self.weights.extend([0, 0, 0, 0, 0]) if item.row is not None: total = self.weights[item.row] + item.width - if total > 10: - raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)') + if total > self.max_weight: + 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: @@ -134,7 +131,7 @@ class _ViewWeights: self.weights[index] += item.width item._rendered_row = index - def remove_item(self, item: Union[Item, View]) -> None: + def remove_item(self, item: Item) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None @@ -185,8 +182,6 @@ class View: for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): children[name] = member - if cls.__discord_ui_container__ and isinstance(member, View): - children[name] = member if len(children) > 25: raise TypeError('View cannot have more than 25 children') @@ -203,7 +198,7 @@ class View: children.append(item) return children - def __init__(self, *, timeout: Optional[float] = 180.0, row: Optional[int] = None): + def __init__(self, *, timeout: Optional[float] = 180.0): self.__timeout = timeout self._children: List[Item[Self]] = self._init_children() self.__weights = _ViewWeights(self._children) @@ -213,8 +208,6 @@ class View: self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - self.row: Optional[int] = row - self._rendered_row: Optional[int] = None def _is_v2(self) -> bool: return False @@ -257,7 +250,12 @@ class View: # helper mapping to find action rows for items that are not # v2 components - for child in self._children: + def key(item: Item) -> int: + return item._rendered_row or 0 + + # instead of grouping by row we will sort it so it is added + # in order and should work as the original implementation + for child in sorted(self._children, key=key): if child._is_v2(): components.append(child.to_component_dict()) else: @@ -619,21 +617,14 @@ class ViewStore: pattern = item.__discord_ui_compiled_template__ self._dynamic_items[pattern] = item.__class__ elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore - is_fully_dynamic = False - - # components V2 containers allow for views to exist inside them - # with dispatchable items, so we iterate over it and add it - # to the store - if hasattr(view, '_views'): - for v in view._views: - for item in v._children: - if isinstance(item, DynamicItem): - pattern = item.__discord_ui_compiled_template__ - self._dynamic_items[pattern] = item.__class__ - elif item.is_dispatchable(): - dispatch_info[(item.type.value, item.custom_id)] = item - is_fully_dynamic = False + if getattr(item, '__discord_ui_container__', False): + is_fully_dynamic = item._update_store_data( # type: ignore + dispatch_info, + self._dynamic_items, + ) + else: + dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore + is_fully_dynamic = False view._cache_key = message_id if message_id is not None and not is_fully_dynamic: