diff --git a/discord/components.py b/discord/components.py index 06caf24f2..9536e93a3 100644 --- a/discord/components.py +++ b/discord/components.py @@ -73,6 +73,11 @@ if TYPE_CHECKING: UnfurledMediaItem as UnfurledMediaItemPayload, LabelComponent as LabelComponentPayload, FileUploadComponent as FileUploadComponentPayload, + RadioGroupComponent as RadioGroupComponentPayload, + RadioGroupOption as RadioGroupOptionPayload, + CheckboxGroupComponent as CheckboxGroupComponentPayload, + CheckboxGroupOption as CheckboxGroupOptionPayload, + CheckboxComponent as CheckboxComponentPayload, ) from .emoji import Emoji @@ -92,6 +97,7 @@ if TYPE_CHECKING: 'SectionComponent', 'Component', ] + OptionPayload = Union[SelectOptionPayload, RadioGroupOptionPayload, CheckboxGroupOptionPayload] __all__ = ( @@ -114,6 +120,11 @@ __all__ = ( 'SeparatorComponent', 'LabelComponent', 'FileUploadComponent', + 'RadioGroupComponent', + 'CheckboxGroupComponent', + 'CheckboxComponent', + 'RadioGroupOption', + 'CheckboxGroupOption', ) @@ -170,6 +181,71 @@ class Component: raise NotImplementedError +class BaseOption: + """Represents a base option for components that have options. + + This currently implements: + + - :class:`SelectOption` + - :class:`RadioGroupOption` + - :class:`CheckboxGroupOption` + + .. versionadded:: 2.7 + """ + + __slots__: Tuple[str, ...] = ('label', 'value', 'description', 'default') + + __repr_info__: ClassVar[Tuple[str, ...]] = ('label', 'value', 'description', 'default') + + def __init__( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + self.label: str = label + self.value: str = label if value is MISSING else value + self.description: Optional[str] = description + self.default: bool = default + + def __repr__(self) -> str: + attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__repr_info__) + return f'<{self.__class__.__name__} {attrs}>' + + def __str__(self) -> str: + base = self.label + + if self.description: + return f'{base}\n{self.description}' + return base + + @classmethod + def from_dict(cls, data: OptionPayload) -> Self: + return cls( + label=data['label'], + value=data['value'], + description=data.get('description'), + default=data.get('default', False), + ) + + def to_dict(self) -> OptionPayload: + payload: OptionPayload = { + 'label': self.label, + 'value': self.value, + 'default': self.default, + } + + if self.description: + payload['description'] = self.description + + return payload + + def copy(self) -> Self: + return self.__class__.from_dict(self.to_dict()) + + class ActionRow(Component): """Represents a Discord Bot UI Kit Action Row. @@ -416,7 +492,7 @@ class SelectMenu(Component): return payload -class SelectOption: +class SelectOption(BaseOption): """Represents a select menu's option. These can be created by users. @@ -454,13 +530,8 @@ class SelectOption: Whether this option is selected by default. """ - __slots__: Tuple[str, ...] = ( - 'label', - 'value', - 'description', - '_emoji', - 'default', - ) + __slots__: Tuple[str, ...] = BaseOption.__slots__ + ('_emoji',) + __repr_info__ = BaseOption.__repr_info__ + ('emoji',) def __init__( self, @@ -471,18 +542,9 @@ class SelectOption: emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, default: bool = False, ) -> None: - self.label: str = label - self.value: str = label if value is MISSING else value - self.description: Optional[str] = description + super().__init__(label=label, value=value, description=description, default=default) self.emoji = emoji - self.default: bool = default - - def __repr__(self) -> str: - return ( - f'' - ) def __str__(self) -> str: if self.emoji: @@ -512,7 +574,7 @@ class SelectOption: self._emoji = None @classmethod - def from_dict(cls, data: SelectOptionPayload) -> SelectOption: + def from_dict(cls, data: SelectOptionPayload) -> Self: try: emoji = PartialEmoji.from_dict(data['emoji']) # pyright: ignore[reportTypedDictNotRequiredAccess] except KeyError: @@ -522,28 +584,18 @@ class SelectOption: label=data['label'], value=data['value'], description=data.get('description'), - emoji=emoji, default=data.get('default', False), + emoji=emoji, ) def to_dict(self) -> SelectOptionPayload: - payload: SelectOptionPayload = { - 'label': self.label, - 'value': self.value, - 'default': self.default, - } + payload: SelectOptionPayload = super().to_dict() # type: ignore if self.emoji: payload['emoji'] = self.emoji.to_dict() - if self.description: - payload['description'] = self.description - return payload - def copy(self) -> SelectOption: - return self.__class__.from_dict(self.to_dict()) - class TextInput(Component): """Represents a text input from the Discord Bot UI Kit. @@ -1453,6 +1505,248 @@ class FileUploadComponent(Component): return payload +class RadioGroupComponent(Component): + """Represents a radio group component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a radio group is + :class:`discord.ui.RadioGroup` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + options: List[:class:`RadioGroupOption`] + A list of options that can be selected in this group. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'required', 'id', 'options') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: RadioGroupComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.required: bool = data.get('required', True) + self.id: Optional[int] = data.get('id') + self.options: List[RadioGroupOption] = [RadioGroupOption.from_dict(option) for option in data.get('options', [])] + + @property + def type(self) -> Literal[ComponentType.radio_group]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.radio_group + + def to_dict(self) -> RadioGroupComponentPayload: + payload: RadioGroupComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + if self.options: + payload['options'] = [option.to_dict() for option in self.options] + + return payload + + +class RadioGroupOption(BaseOption): + """Represents a radio group's option + + These can be created by users. + + .. versionadded:: 2.7 + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Attributes + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the + label. + description: Optional[:class:`str`] + An additional description of the option, if any. + default: :class:`bool` + Whether this option is selected by default. + """ + + +class CheckboxGroupComponent(Component): + """Represents a checkbox group component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a checkbox group is + :class:`discord.ui.CheckboxGroup` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + min_values: :class:`int` + The minimum number of options that must be selected in this component. + Must be between 0 and 10. Defaults to 0. + max_values: :class:`int` + The maximum number of options that can be selected in this component. + Must be between 1 and 10. Defaults to 1. + options: List[:class:`CheckboxGroupOption`] + A list of options that can be selected in this group. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'required', 'id', 'min_values', 'max_values', 'options') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: CheckboxGroupComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.required: bool = data.get('required', True) + self.id: Optional[int] = data.get('id') + self.min_values: int = data.get('min_values', 0) + self.max_values: int = data.get('max_values', 1) + self.options: List[CheckboxGroupOption] = [ + CheckboxGroupOption.from_dict(option) for option in data.get('options', []) + ] + + @property + def type(self) -> Literal[ComponentType.checkbox_group]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.checkbox_group + + def to_dict(self) -> CheckboxGroupComponentPayload: + payload: CheckboxGroupComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'min_values': self.min_values, + 'max_values': self.max_values, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + if self.options: + payload['options'] = [option.to_dict() for option in self.options] + + return payload + + +class CheckboxGroupOption(BaseOption): + """Represents a checkbox group's option + + These can be created by users. + + .. versionadded:: 2.7 + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Attributes + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the + label. + description: Optional[:class:`str`] + An additional description of the option, if any. + default: :class:`bool` + Whether this option is selected by default. + """ + + +class CheckboxComponent(Component): + """Represents a checkbox component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a checkbox is + :class:`discord.ui.Checkbox` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + default: :class:`bool` + Whether this checkbox is selected by default. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'default', 'id') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: CheckboxComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.id: Optional[int] = data.get('id') + self.default: bool = data.get('default', False) + + @property + def type(self) -> Literal[ComponentType.checkbox]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.checkbox + + def to_dict(self) -> CheckboxComponentPayload: + payload: CheckboxComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'default': self.default, + } + if self.id is not None: + payload['id'] = self.id + + return payload + + def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) @@ -1480,3 +1774,9 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] return LabelComponent(data, state) elif data['type'] == 19: return FileUploadComponent(data) + elif data['type'] == 21: + return RadioGroupComponent(data) + elif data['type'] == 22: + return CheckboxGroupComponent(data) + elif data['type'] == 23: + return CheckboxComponent(data) diff --git a/discord/enums.py b/discord/enums.py index 260222894..025b54cb4 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -694,6 +694,10 @@ class ComponentType(Enum): container = 17 label = 18 file_upload = 19 + # checkpoint = 20 + radio_group = 21 + checkbox_group = 22 + checkbox = 23 def __int__(self) -> int: return self.value diff --git a/discord/types/components.py b/discord/types/components.py index 5522da38a..0d7b6d80d 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -30,7 +30,7 @@ from typing_extensions import NotRequired from .emoji import PartialEmoji from .channel import ChannelType -ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19, 21, 22, 23] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] @@ -43,6 +43,13 @@ class ComponentBase(TypedDict): type: int +class OptionBase(TypedDict): + label: str + value: str + default: NotRequired[bool] + description: NotRequired[str] + + class ActionRow(ComponentBase): type: Literal[1] components: List[ActionRowChildComponent] @@ -59,11 +66,7 @@ class ButtonComponent(ComponentBase): sku_id: NotRequired[str] -class SelectOption(TypedDict): - label: str - value: str - default: bool - description: NotRequired[str] +class SelectOption(OptionBase): emoji: NotRequired[PartialEmoji] @@ -192,7 +195,7 @@ class LabelComponent(ComponentBase): type: Literal[18] label: str description: NotRequired[str] - component: Union[SelectMenu, TextInput, FileUploadComponent] + component: LabelChildComponent class FileUploadComponent(ComponentBase): @@ -203,6 +206,34 @@ class FileUploadComponent(ComponentBase): required: NotRequired[bool] +class RadioGroupComponent(ComponentBase): + type: Literal[21] + custom_id: str + options: NotRequired[List[RadioGroupOption]] + required: NotRequired[bool] + + +RadioGroupOption = OptionBase + + +class CheckboxGroupComponent(ComponentBase): + type: Literal[22] + custom_id: str + options: NotRequired[List[CheckboxGroupOption]] + max_values: NotRequired[int] + min_values: NotRequired[int] + required: NotRequired[bool] + + +CheckboxGroupOption = OptionBase + + +class CheckboxComponent(ComponentBase): + type: Literal[23] + custom_id: str + default: NotRequired[bool] + + ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] ContainerChildComponent = Union[ ActionRow, @@ -211,8 +242,21 @@ ContainerChildComponent = Union[ FileComponent, SectionComponent, SectionComponent, - ContainerComponent, SeparatorComponent, ThumbnailComponent, ] -Component = Union[ActionRowChildComponent, LabelComponent, FileUploadComponent, ContainerChildComponent] +LabelChildComponent = Union[ + TextInput, + SelectMenu, + FileUploadComponent, + RadioGroupComponent, + CheckboxGroupComponent, + CheckboxComponent, +] +Component = Union[ + ActionRowChildComponent, + LabelComponent, + LabelChildComponent, + ContainerChildComponent, + ContainerComponent, +] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 6e6d9ef39..463800a90 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -27,7 +27,12 @@ from __future__ import annotations from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union, Optional from typing_extensions import NotRequired -from .channel import ChannelTypeWithoutThread, GuildChannel, InteractionDMChannel, GroupDMChannel +from .channel import ( + ChannelTypeWithoutThread, + GuildChannel, + InteractionDMChannel, + GroupDMChannel, +) from .sku import Entitlement from .threads import ThreadType, ThreadMetadata from .member import Member @@ -223,14 +228,40 @@ class ModalSubmitFileUploadInteractionData(ComponentBase): values: List[str] -ModalSubmitComponentItemInteractionData = Union[ - ModalSubmitSelectInteractionData, ModalSubmitTextInputInteractionData, ModalSubmitFileUploadInteractionData +class ModalSubmitRadioGroupInteractionData(ComponentBase): + type: Literal[21] + custom_id: str + id: int + value: Optional[str] + + +class ModalSubmitCheckboxGroupInteractionData(ComponentBase): + type: Literal[22] + custom_id: str + id: int + values: List[str] + + +class ModalSubmitCheckboxInteractionData(ComponentBase): + type: Literal[23] + custom_id: str + id: int + value: bool + + +ModalSubmitLabelComponentItemInteractionData = Union[ + ModalSubmitSelectInteractionData, + ModalSubmitTextInputInteractionData, + ModalSubmitFileUploadInteractionData, + ModalSubmitRadioGroupInteractionData, + ModalSubmitCheckboxGroupInteractionData, + ModalSubmitCheckboxInteractionData, ] class ModalSubmitActionRowInteractionData(TypedDict): type: Literal[1] - components: List[ModalSubmitComponentItemInteractionData] + components: List[ModalSubmitTextInputInteractionData] class ModalSubmitTextDisplayInteractionData(ComponentBase): @@ -240,7 +271,7 @@ class ModalSubmitTextDisplayInteractionData(ComponentBase): class ModalSubmitLabelInteractionData(ComponentBase): type: Literal[18] - component: ModalSubmitComponentItemInteractionData + component: ModalSubmitLabelComponentItemInteractionData ModalSubmitComponentInteractionData = Union[ @@ -301,7 +332,12 @@ class ModalSubmitInteraction(_BaseInteraction): data: ModalSubmitInteractionData -Interaction = Union[PingInteraction, ApplicationCommandInteraction, MessageComponentInteraction, ModalSubmitInteraction] +Interaction = Union[ + PingInteraction, + ApplicationCommandInteraction, + MessageComponentInteraction, + ModalSubmitInteraction, +] class MessageInteraction(TypedDict): @@ -349,7 +385,8 @@ class MessageComponentMessageInteractionMetadata(_MessageInteractionMetadata): class ModalSubmitMessageInteractionMetadata(_MessageInteractionMetadata): type: Literal[5] triggering_interaction_metadata: Union[ - ApplicationCommandMessageInteractionMetadata, MessageComponentMessageInteractionMetadata + ApplicationCommandMessageInteractionMetadata, + MessageComponentMessageInteractionMetadata, ] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 061c1ef60..c5ce5e390 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -26,3 +26,5 @@ from .thumbnail import * from .action_row import * from .label import * from .file_upload import * +from .radio import * +from .checkbox import * diff --git a/discord/ui/checkbox.py b/discord/ui/checkbox.py new file mode 100644 index 000000000..e64895ed2 --- /dev/null +++ b/discord/ui/checkbox.py @@ -0,0 +1,391 @@ +""" +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, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import CheckboxGroupComponent, CheckboxComponent, CheckboxGroupOption +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..interactions import Interaction + from ..types.interactions import ( + ModalSubmitCheckboxGroupInteractionData as ModalSubmitCheckboxGroupInteractionDataPayload, + ModalSubmitCheckboxInteractionData as ModalSubmitCheckboxInteractionDataPayload, + ) + from ..types.components import ( + CheckboxGroupComponent as CheckboxGroupComponentPayload, + CheckboxComponent as CheckboxComponentPayload, + ) + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'CheckboxGroup', + 'Checkbox', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class CheckboxGroup(Item[V]): + """Represents a checkbox group component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + options: List[:class:`discord.CheckboxGroupOption`] + A list of options that can be selected in this checkbox group. + Can only contain up to 10 items. + max_values: Optional[:class:`int`] + The maximum number of options that can be selected in this component. + Must be between 1 and 10. Defaults to 1. + min_values: Optional[:class:`int`] + The minimum number of options that must be selected in this component. + Must be between 0 and 10. Defaults to 0. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'options', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + min_values: Optional[int] = None, + max_values: Optional[int] = None, + options: List[CheckboxGroupOption] = MISSING, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: CheckboxGroupComponent = CheckboxGroupComponent._raw_construct( + id=id, + custom_id=custom_id, + required=required, + options=options or [], + min_values=min_values, + max_values=max_values, + ) + self.id = id + self._values: List[str] = [] + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + @property + def values(self) -> List[str]: + """List[:class:`str`]: A list of values that have been selected by the user.""" + return self._values + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.checkbox_group]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.checkbox_group + + @property + def options(self) -> List[CheckboxGroupOption]: + """List[:class:`discord.CheckboxGroupOption`]: A list of options that can be selected in this menu.""" + return self._underlying.options + + @options.setter + def options(self, value: List[CheckboxGroupOption]) -> None: + if not isinstance(value, list) or not all(isinstance(obj, CheckboxGroupOption) for obj in value): + raise TypeError('options must be a list of CheckboxGroupOption') + self._underlying.options = value + + @property + def min_values(self) -> int: + """:class:`int`: The minimum number of options that must be selected before submitting the modal.""" + return self._underlying.min_values + + @min_values.setter + def min_values(self, value: int) -> None: + self._underlying.min_values = int(value) + + @property + def max_values(self) -> int: + """:class:`int`: The maximum number of options that can be selected before submitting the modal.""" + return self._underlying.max_values + + @max_values.setter + def max_values(self, value: int) -> None: + self._underlying.max_values = int(value) + + def add_option( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + """Adds an option to the checkbox group. + + To append a pre-existing :class:`discord.CheckboxGroupOption` use the + :meth:`append_option` method instead. + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not given, defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + option = CheckboxGroupOption( + label=label, + value=value, + description=description, + default=default, + ) + + self.append_option(option) + + def append_option(self, option: CheckboxGroupOption) -> None: + """Appends an option to the checkbox group. + + Parameters + ----------- + option: :class:`discord.CheckboxGroupOption` + The option to append to the checkbox group. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + if len(self._underlying.options) >= 10: + raise ValueError('maximum number of options already provided (10)') + + self._underlying.options.append(option) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> CheckboxGroupComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: CheckboxGroupComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitCheckboxGroupInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._values = data.get('values', []) + + @classmethod + def from_component(cls, component: CheckboxGroupComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + options=component.options, + required=component.required, + min_values=component.min_values, + max_values=component.max_values, + ) + return self + + def is_dispatchable(self) -> bool: + return False + + +class Checkbox(Item[V]): + """Represents a checkbox component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + default: :class:`bool` + Whether this checkbox is selected by default. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'default', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + default: bool = False, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: CheckboxComponent = CheckboxComponent._raw_construct( + id=id, + custom_id=custom_id, + default=default, + ) + self.id = id + self._value: bool = default + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + @property + def value(self) -> bool: + """:class:`bool`: ``True`` if this checkbox was selected, otherwise ``False``.""" + return self._value + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.checkbox]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.checkbox + + @property + def default(self) -> bool: + """:class:`bool`: Whether this checkbox is selected by default.""" + return self._underlying.default + + @default.setter + def default(self, value: bool) -> None: + self._underlying.default = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> CheckboxComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: CheckboxComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitCheckboxInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._value = data.get('value', False) + + @classmethod + def from_component(cls, component: CheckboxComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + default=component.default, + ) + return self + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/item.py b/discord/ui/item.py index 4c0dd6110..c6f165d5c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -87,6 +87,9 @@ class Item(Generic[V]): - :class:`discord.ui.TextDisplay` - :class:`discord.ui.Thumbnail` - :class:`discord.ui.Label` + - :class:`discord.ui.RadioGroup` + - :class:`discord.ui.CheckboxGroup` + - :class:`discord.ui.Checkbox` .. versionadded:: 2.0 """ diff --git a/discord/ui/radio.py b/discord/ui/radio.py new file mode 100644 index 000000000..4c02c6638 --- /dev/null +++ b/discord/ui/radio.py @@ -0,0 +1,246 @@ +""" +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, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import RadioGroupComponent, RadioGroupOption +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..interactions import Interaction + from ..types.interactions import ( + ModalSubmitRadioGroupInteractionData as ModalSubmitRadioGroupInteractionDataPayload, + ) + from ..types.components import RadioGroupComponent as RadioGroupComponentPayload + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'RadioGroup', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class RadioGroup(Item[V]): + """Represents a radio group component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + options: List[:class:`discord.RadioGroupOption`] + A list of options that can be selected in this radio group. + Can contain between 2 and 10 items. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'options', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + options: List[RadioGroupOption] = MISSING, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: RadioGroupComponent = RadioGroupComponent._raw_construct( + id=id, + custom_id=custom_id, + required=required, + options=options or [], + ) + self.id = id + self._value: Optional[str] = None + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + @property + def value(self) -> Optional[str]: + """Optional[:class:`str`]: The value have been selected by the user, if any.""" + return self._value + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.radio_group]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.radio_group + + @property + def options(self) -> List[RadioGroupOption]: + """List[:class:`discord.RadioGroupOption`]: A list of options that can be selected in this radio group.""" + return self._underlying.options + + @options.setter + def options(self, value: List[RadioGroupOption]) -> None: + if not isinstance(value, list) or not all(isinstance(obj, RadioGroupOption) for obj in value): + raise TypeError('options must be a list of RadioGroupOption') + + self._underlying.options = value + + def add_option( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + """Adds an option to the group. + + To append a pre-existing :class:`discord.RadioGroupOption` use the + :meth:`append_option` method instead. + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not given, defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + option = RadioGroupOption( + label=label, + value=value, + description=description, + default=default, + ) + + self.append_option(option) + + def append_option(self, option: RadioGroupOption) -> None: + """Appends an option to the group. + + Parameters + ----------- + option: :class:`discord.RadioGroupOption` + The option to append to the group. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + if len(self._underlying.options) >= 10: + raise ValueError('maximum number of options already provided (10)') + + self._underlying.options.append(option) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> RadioGroupComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: RadioGroupComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitRadioGroupInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._value = data.get('value') + + @classmethod + def from_component(cls, component: RadioGroupComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + options=component.options, + required=component.required, + ) + return self + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/select.py b/discord/ui/select.py index b003f8fcb..4c516358a 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -506,10 +506,8 @@ class Select(BaseSelect[V]): @options.setter def options(self, value: List[SelectOption]) -> None: - if not isinstance(value, list): + if not isinstance(value, list) or not all(isinstance(obj, SelectOption) for obj in value): raise TypeError('options must be a list of SelectOption') - if not all(isinstance(obj, SelectOption) for obj in value): - raise TypeError('all list items must subclass SelectOption') self._underlying.options = value @@ -576,7 +574,7 @@ class Select(BaseSelect[V]): """ if len(self._underlying.options) >= 25: - raise ValueError('maximum number of options already provided') + raise ValueError('maximum number of options already provided (25)') self._underlying.options.append(option) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 107e4e2e4..2a5543c60 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -202,6 +202,33 @@ FileUploadComponent :members: :inherited-members: +RadioGroupComponent +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: RadioGroupComponent + +.. autoclass:: RadioGroupComponent() + :members: + :inherited-members: + +CheckboxComponent +~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxComponent + +.. autoclass:: CheckboxComponent() + :members: + :inherited-members: + +CheckboxGroupComponent +~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxGroupComponent + +.. autoclass:: CheckboxGroupComponent() + :members: + :inherited-members: + AppCommand ~~~~~~~~~~~ @@ -330,6 +357,21 @@ MediaGalleryItem .. autoclass:: MediaGalleryItem :members: +RadioGroupOption +~~~~~~~~~~~~~~~~ + +.. attributetable:: RadioGroupOption + +.. autoclass:: RadioGroupOption() + :members: + +CheckboxGroupOption +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxGroupOption + +.. autoclass:: CheckboxGroupOption() + :members: Enumerations ------------- @@ -494,6 +536,24 @@ Enumerations Represents a file upload component, usually in a modal. .. versionadded:: 2.7 + + .. attribute:: radio_group + + Represents a radio group component. + + .. versionadded:: 2.7 + + .. attribute:: checkbox_group + + Represents a checkbox group component. + + .. versionadded:: 2.7 + + .. attribute:: checkbox + + Represents a checkbox component. + + .. versionadded:: 2.7 .. class:: ButtonStyle @@ -882,6 +942,39 @@ FileUpload :inherited-members: :exclude-members: callback, interaction_check +RadioGroup +~~~~~~~~~~~ + +.. attributetable:: discord.ui.RadioGroup + +.. autoclass:: discord.ui.RadioGroup + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +Checkbox +~~~~~~~~~ + +.. attributetable:: discord.ui.Checkbox + +.. autoclass:: discord.ui.Checkbox + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +CheckboxGroup +~~~~~~~~~~~~~~ + +.. attributetable:: discord.ui.CheckboxGroup + +.. autoclass:: discord.ui.CheckboxGroup + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + .. _discord_app_commands: Application Commands