From 18f72f58fd5111b60d8d1879d2704cfe34aeaa76 Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:33:55 +0100 Subject: [PATCH] idk some things --- discord/ui/action_row.py | 292 +++++++++++++++++++++++++++++++++++++++ discord/ui/button.py | 2 + discord/ui/select.py | 2 + discord/ui/view.py | 133 ++++++++++++------ 4 files changed, 384 insertions(+), 45 deletions(-) create mode 100644 discord/ui/action_row.py diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py new file mode 100644 index 000000000..160a9eca8 --- /dev/null +++ b/discord/ui/action_row.py @@ -0,0 +1,292 @@ +""" +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 + +import inspect +import os +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Coroutine, + Dict, + List, + Literal, + Optional, + Sequence, + Type, + TypeVar, + Union, +) + +from .item import Item, ItemCallbackType +from .button import Button +from .select import Select, SelectCallbackDecorator +from ..enums import ButtonStyle, ComponentType, ChannelType +from ..partial_emoji import PartialEmoji +from ..utils import MISSING + +if TYPE_CHECKING: + from .view import LayoutView + from .select import ( + BaseSelectT, + ValidDefaultValues, + MentionableSelectT, + ChannelSelectT, + RoleSelectT, + UserSelectT, + SelectT + ) + from ..emoji import Emoji + from ..components import SelectOption + from ..interactions import Interaction + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('ActionRow',) + + +class _ActionRowCallback: + __slots__ = ('row', 'callback', 'item') + + def __init__(self, callback: ItemCallbackType[Any, Any], row: ActionRow, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback + self.row: ActionRow = row + self.item: Item[Any] = item + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.row, interaction, self.item) + + +class ActionRow(Item[V]): + """Represents a UI action row. + + This object can be inherited. + + .. versionadded:: 2.6 + + Parameters + ---------- + id: Optional[:class:`str`] + The ID of this action row. Defaults to ``None``. + """ + + __action_row_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] + __discord_ui_action_row__: ClassVar[bool] = True + + def __init__(self, *, id: Optional[str] = None) -> None: + super().__init__() + + self.id: str = id or os.urandom(16).hex() + self._children: List[Item[Any]] = self._init_children() + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemCallbackType[Any, Any]] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if hasattr(member, '__discord_ui_model_type__'): + children[name] = member + + if len(children) > 5: + raise TypeError('ActionRow cannot have more than 5 children') + + cls.__action_row_children_items__ = list(children.values()) + + def _init_children(self) -> List[Item[Any]]: + children = [] + + for func in self.__action_row_children_items__: + item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) + item.callback = _ActionRowCallback(func, self, item) + item._parent = self # type: ignore + setattr(self, func.__name__, item) + children.append(item) + return children + + def _update_children_view(self, view: LayoutView) -> None: + for child in self._children: + child._view = view + + def _is_v2(self) -> bool: + # although it is not really a v2 component the only usecase here is for + # LayoutView which basically represents the top-level payload of components + # and ActionRow is only allowed there anyways. + # If the user tries to add any V2 component to a View instead of LayoutView + # it should error anyways. + return True + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.action_row]: + return ComponentType.action_row + + def button( + self, + *, + label: Optional[str] = None, + custom_id: Optional[str] = None, + disabled: bool = False, + style: ButtonStyle = ButtonStyle.secondary, + emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + ) -> Callable[[ItemCallbackType[V, Button[V]]], Button[V]]: + """A decorator that attaches a button to a component. + + The function being decorated should have three parameters, ``self`` representing + the :class:`discord.ui.LayoutView`, the :class:`discord.Interaction` you receive and + the :class:`discord.ui.Button` being pressed. + + .. note:: + + Buttons with a URL or a SKU cannot be created with this function. + Consider creating a :class:`Button` manually and adding it via + :meth:`ActionRow.add_item` instead. This is beacuse these buttons + cannot have a callback associated with them since Discord does not + do any processing with them. + + Parameters + ---------- + label: Optional[:class:`str`] + The label of the button, if any. + Can only be up to 80 characters. + custom_id: Optional[:class:`str`] + The ID of the button that gets received during an interaction. + It is recommended to not set this parameters to prevent conflicts. + Can only be up to 100 characters. + style: :class:`.ButtonStyle` + The style of the button. Defaults to :attr:`.ButtonStyle.grey`. + disabled: :class:`bool` + Whether the button is disabled or not. Defaults to ``False``. + emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]] + The emoji of the button. This can be in string form or a :class:`.PartialEmoji` + or a full :class:`.Emoji`. + """ + + def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: + if not inspect.iscoroutinefunction(func): + raise TypeError('button function must be a coroutine function') + + func.__discord_ui_modal_type__ = Button + func.__discord_ui_model_kwargs__ = { + 'style': style, + 'custom_id': custom_id, + 'url': None, + 'disabled': disabled, + 'label': label, + 'emoji': emoji, + 'row': None, + 'sku_id': None, + } + return func + + return decorator # type: ignore + + def select( + *, + cls: Type[BaseSelectT] = Select[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = MISSING, + placeholder: Optional[str] = None, + custom_id: str = MISSING, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + default_values: Sequence[ValidDefaultValues] = MISSING, + ) -> SelectCallbackDecorator[V, BaseSelectT]: + """A decorator that attaches a select menu to a component. + + The function being decorated should have three parameters, ``self`` representing + the :class:`discord.ui.View`, the :class:`discord.Interaction` you receive and + the chosen select class. + + To obtain the selected values inside the callback, you can use the ``values`` attribute of the chosen class in the callback. The list of values + will depend on the type of select menu used. View the table below for more information. + + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | Select Type | Resolved Values | + +========================================+=================================================================================================================+ + | :class:`discord.ui.Select` | List[:class:`str`] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.UserSelect` | List[Union[:class:`discord.Member`, :class:`discord.User`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.RoleSelect` | List[:class:`discord.Role`] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.MentionableSelect` | List[Union[:class:`discord.Role`, :class:`discord.Member`, :class:`discord.User`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.ChannelSelect` | List[Union[:class:`~discord.app_commands.AppCommandChannel`, :class:`~discord.app_commands.AppCommandThread`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + + .. versionchanged:: 2.1 + Added the following keyword-arguments: ``cls``, ``channel_types`` + + Example + --------- + .. code-block:: python3 + + class ActionRow(discord.ui.ActionRow): + + @discord.ui.select(cls=ChannelSelect, channel_types=[discord.ChannelType.text]) + async def select_channels(self, interaction: discord.Interaction, select: ChannelSelect): + return await interaction.response.send_message(f'You selected {select.values[0].mention}') + + Parameters + ------------ + cls: Union[Type[:class:`discord.ui.Select`], Type[:class:`discord.ui.UserSelect`], Type[:class:`discord.ui.RoleSelect`], \ + Type[:class:`discord.ui.MentionableSelect`], Type[:class:`discord.ui.ChannelSelect`]] + The class to use for the select menu. Defaults to :class:`discord.ui.Select`. You can use other + select types to display different select menus to the user. See the table above for the different + values you can get from each select type. Subclasses work as well, however the callback in the subclass will + get overridden. + placeholder: Optional[:class:`str`] + The placeholder text that is shown if nothing is selected, if any. + Can only be up to 150 characters. + custom_id: :class:`str` + The ID of the select menu that gets received during an interaction. + It is recommended not to set this parameter to prevent conflicts. + Can only be up to 100 characters. + min_values: :class:`int` + The minimum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 0 and 25. + max_values: :class:`int` + The maximum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 1 and 25. + options: List[:class:`discord.SelectOption`] + A list of options that can be selected in this menu. This can only be used with + :class:`Select` instances. + Can only contain up to 25 items. + channel_types: List[:class:`~discord.ChannelType`] + The types of channels to show in the select menu. Defaults to all channels. This can only be used + with :class:`ChannelSelect` instances. + disabled: :class:`bool` + Whether the select is disabled or not. Defaults to ``False``. + default_values: Sequence[:class:`~discord.abc.Snowflake`] + A list of objects representing the default values for the select menu. This cannot be used with regular :class:`Select` instances. + If ``cls`` is :class:`MentionableSelect` and :class:`.Object` is passed, then the type must be specified in the constructor. + Number of items must be in range of ``min_values`` and ``max_values``. + """ diff --git a/discord/ui/button.py b/discord/ui/button.py index 43bd3a8b0..f15910eff 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: from typing_extensions import Self from .view import View + from .action_row import ActionRow from ..emoji import Emoji from ..types.components import ButtonComponent as ButtonComponentPayload @@ -144,6 +145,7 @@ class Button(Item[V]): emoji=emoji, sku_id=sku_id, ) + self._parent: Optional[ActionRow] = None self.row = row @property diff --git a/discord/ui/select.py b/discord/ui/select.py index 1ef085cc5..b2534e146 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -73,6 +73,7 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias, TypeGuard from .view import View + from .action_row import ActionRow from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread @@ -258,6 +259,7 @@ class BaseSelect(Item[V]): ) self.row = row + self._parent: Optional[ActionRow] = None self._values: List[PossibleValue] = [] @property diff --git a/discord/ui/view.py b/discord/ui/view.py index e490f1444..e19f8bc6c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -36,7 +36,6 @@ from typing import ( TYPE_CHECKING, Tuple, Type, - Union, ) from functools import partial from itertools import groupby @@ -47,6 +46,7 @@ import sys import time import os from .item import Item, ItemCallbackType +from .action_row import ActionRow from .dynamic import DynamicItem from ..components import ( Component, @@ -153,9 +153,6 @@ class _ViewWeights: raise ValueError('could not find open space for item') 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 > 5: @@ -191,7 +188,7 @@ class _ViewCallback: return self.callback(self.view, interaction, self.item) -class View: +class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? """Represents a UI view. This object must be inherited to create a UI within Discord. @@ -208,15 +205,15 @@ class View: __discord_ui_view__: ClassVar[bool] = True __discord_ui_modal__: ClassVar[bool] = False __discord_ui_container__: ClassVar[bool] = False - __view_children_items__: ClassVar[List[Union[ItemCallbackType[Any, Any], Item[Any]]]] = [] + __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] def __init_subclass__(cls) -> None: super().__init_subclass__() - children: Dict[str, Union[ItemCallbackType[Any, Any], Item]] = {} + children: Dict[str, ItemCallbackType[Any, Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): - if hasattr(member, '__discord_ui_model_type__') or isinstance(member, Item): + if hasattr(member, '__discord_ui_model_type__'): children[name] = member if len(children) > 25: @@ -228,15 +225,11 @@ class View: children = [] for func in self.__view_children_items__: - if isinstance(func, Item): - func._view = self - children.append(func) - else: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(func, self, item) # type: ignore - item._view = self - setattr(self, func.__name__, item) - children.append(item) + item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) + item.callback = _ViewCallback(func, self, item) # type: ignore + item._view = self + setattr(self, func.__name__, item) + children.append(item) return children def __init__(self, *, timeout: Optional[float] = 180.0): @@ -286,36 +279,22 @@ class View: return any(c._is_v2() for c in self.children) def to_components(self) -> List[Dict[str, Any]]: - components: List[Dict[str, Any]] = [] - rows_index: Dict[int, int] = {} - # helper mapping to find action rows for items that are not - # v2 components - 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 - # this will append directly the v2 Components into the list - # and will add to an action row the loose items, such as - # buttons and selects - for child in sorted(self._children, key=key): - if child._is_v2(): - components.append(child.to_component_dict()) - else: - row = child._rendered_row or 0 - index = rows_index.get(row) + children = sorted(self._children, key=key) + 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 - if index is not None: - components[index]['components'].append(child.to_component_dict()) - else: - components.append( - { - 'type': 1, - 'components': [child.to_component_dict()], - }, - ) - rows_index[row] = len(components) - 1 + components.append( + { + 'type': 1, + 'components': children, + } + ) return components @@ -401,8 +380,9 @@ class View: TypeError An :class:`Item` was not passed. ValueError - Maximum number of children has been exceeded (25) - or the row the item is trying to be added to is full. + Maximum number of children has been exceeded (25), the + row the item is trying to be added to is full or the item + you tried to add is not allowed in this View. """ if len(self._children) >= 25: @@ -411,6 +391,11 @@ class View: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') + if item._is_v2() and not self._is_v2(): + raise ValueError( + 'The item can only be added on LayoutView' + ) + self.__weights.add_item(item) item._view = self @@ -614,6 +599,64 @@ class View: return await self.__stopped +class LayoutView(View): + __view_children_items__: ClassVar[List[Item[Any]]] = [] + + def __init__(self, *, timeout: Optional[float] = 180) -> None: + super().__init__(timeout=timeout) + self.__weights.weights.extend([0, 0, 0, 0, 0]) + + def __init_subclass__(cls) -> None: + children: Dict[str, Item[Any]] = {} + + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if isinstance(member, Item): + children[name] = member + + if len(children) > 10: + raise TypeError('LayoutView cannot have more than 10 top-level children') + + cls.__view_children_items__ = list(children.values()) + + def _init_children(self) -> List[Item[Self]]: + children = [] + + for i in self.__view_children_items__: + if isinstance(i, Item): + if getattr(i, '_parent', None): + # this is for ActionRows which have decorators such as + # @action_row.button and @action_row.select that will convert + # those callbacks into their types but will have a _parent + # attribute which is checked here so the item is not added twice + continue + i._view = self + if getattr(i, '__discord_ui_action_row__', False): + i._update_children_view(self) # type: ignore + children.append(i) + else: + # guard just in case + raise TypeError( + 'LayoutView can only have items' + ) + return children + + def _is_v2(self) -> bool: + return True + + def to_components(self): + components: List[Dict[str, Any]] = [] + + # sorted by row, which in LayoutView indicates the position of the component in the + # payload instead of in which ActionRow it should be placed on. + for child in sorted(self._children, key=lambda i: i._rendered_row or 0): + components.append( + child.to_component_dict(), + ) + + return child + + class ViewStore: def __init__(self, state: ConnectionState): # entity_id: {(component_type, custom_id): Item}