diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4d485762..429d07da43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ These changes are available on the `master` branch, but have not yet been releas ### Added +- Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & + `MediaGallery` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `index`, `before`, and `after` to `add_item` functions + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added arbitrary kwarg support to `get_item` functions + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `Message.get_view` + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `from_dict`, `add_label`, & `add_text` to `DesignerModal` + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `read`, `save`, and `to_file` to `UnfurledMediaItem` + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) + ### Changed ### Fixed diff --git a/discord/asset.py b/discord/asset.py index 659d6dd5eb..1c5c82ca9f 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -28,6 +28,7 @@ import io import os from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import urlparse import yarl @@ -122,6 +123,12 @@ async def save( with open(fp, "wb") as f: return f.write(data) + @property + def cdn_name(self) -> str | None: + """Attempts to return the filename within this asset's URL, if present.""" + parsed = urlparse(self.url) + return os.path.basename(parsed.path) + class Asset(AssetMixin): """Represents a CDN asset on Discord. diff --git a/discord/components.py b/discord/components.py index d6e1eb89a8..05afcb10e8 100644 --- a/discord/components.py +++ b/discord/components.py @@ -25,7 +25,10 @@ from __future__ import annotations +import io +from os import PathLike, path from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar, overload +from urllib.parse import urlparse from .asset import AssetMixin from .colour import Colour @@ -38,6 +41,7 @@ SeparatorSpacingSize, try_enum, ) +from .file import File from .flags import AttachmentFlags from .partial_emoji import PartialEmoji, _EmojiTag from .utils import MISSING, find, get_slots @@ -956,6 +960,138 @@ def url(self, value: str) -> None: value if value and value.startswith("attachment://") else None ) + @property + def resolved_name(self) -> str | None: + """Attempts to return the filename within this media's URL, if present.""" + if self.url.startswith("attachment://"): + return self.url.replace("attachment://", "") + else: + parsed = urlparse(self.url) + return path.basename(parsed.path) + + async def save( + self, + fp: io.BufferedIOBase | PathLike, + *, + seek_begin: bool = True, + ) -> int: + """|coro| + + Saves this media into a file-like object. + + Parameters + ---------- + fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] + The file-like object to save this media to or the filename + to use. If a filename is passed then a file is created with that + filename and used instead. + seek_begin: :class:`bool` + Whether to seek to the beginning of the file after saving is + successfully done. + + Returns + ------- + :class:`int` + The number of bytes written. + + Raises + ------ + HTTPException + Saving the media failed. + NotFound + The media was deleted. + ValueError + You attempted to download from a local ``attachment://`` URL. + """ + data = await self.read() + + if isinstance(fp, io.BufferedIOBase): + written = fp.write(data) + if seek_begin: + fp.seek(0) + return written + else: + with open(fp, "wb") as f: + return f.write(data) + + async def read(self) -> bytes: + """|coro| + + Retrieves the content of this media as a :class:`bytes` object. + + Returns + ------- + :class:`bytes` + The contents of the media. + + Raises + ------ + HTTPException + Downloading the media failed. + Forbidden + You do not have permissions to access this media. + NotFound + The media was deleted. + ValueError + You attempted to download from a local ``attachment://`` URL. + """ + if self.url.startswith("attachment://"): + raise ValueError("cannot download a local media URL.") + if not self._state: + raise RuntimeError("can only read when media is received from Discord.") + return await self._state.http.get_from_cdn(self.url) + + async def to_file( + self, + filename: str | None = None, + *, + description: str | None = None, + spoiler: bool = False, + ) -> File: + """|coro| + + Converts the media into a :class:`discord.File` suitable for sending via + :meth:`abc.Messageable.send`. + + Parameters + ---------- + filename: :class:`str` + The name to initialize this file with. Defaults to :attr:`resolved_name` if available. + description: Optional[:class:`str`] + The description of this file. + spoiler: :class:`bool` + Whether the file is a spoiler. + + Returns + ------- + :class:`File` + The media as a file suitable for sending. + + Raises + ------ + HTTPException + Downloading the media failed. + Forbidden + You do not have permissions to access this media + NotFound + The media was deleted. + ValueError + You attempted to download from a local ``attachment://`` URL. + """ + name = filename or self.resolved_name + if not name: + raise ValueError( + "no resolved_name available, please provide filename manually." + ) + + data = await self.read() + return File( + io.BytesIO(data), + filename=name, + spoiler=spoiler, + description=description, + ) + @classmethod def from_dict(cls, data: UnfurledMediaItemPayload, state=None) -> UnfurledMediaItem: r = cls(data.get("url")) @@ -1050,6 +1186,9 @@ def __init__(self, url, *, description=None, spoiler=False): self.description: str | None = description self.spoiler: bool = spoiler + def __repr__(self) -> str: + return f"" + @property def url(self) -> str: return self.media.url @@ -1347,7 +1486,7 @@ class Label(Component): def __init__(self, data: LabelComponentPayload): self.type: ComponentType = try_enum(ComponentType, data["type"]) - self.id: int = data["id"] + self.id: int = data.get("id") self.component: Component = _component_factory(data["component"]) self.label: str = data["label"] self.description: str | None = data.get("description") diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index ab40be08ec..5591a870be 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -27,6 +27,8 @@ import contextlib from typing import List +from typing_extensions import Self + import discord from discord.errors import DiscordException from discord.ext.bridge import BridgeContext @@ -916,6 +918,12 @@ def update_custom_view(self, custom_view: discord.ui.View): for item in custom_view.children: self.add_item(item) + def clear_items(self) -> Self: + # Necessary override due to behavior of Item.parent, see #3057 + self.children.clear() + self._View__weights.clear() + return self + def get_page_group_content(self, page_group: PageGroup) -> list[Page]: """Returns a converted list of `Page` objects for the given page group based on the content of its pages.""" return [self.get_page_content(page) for page in page_group.pages] diff --git a/discord/message.py b/discord/message.py index 736397ee4e..0d85d394b8 100644 --- a/discord/message.py +++ b/discord/message.py @@ -93,7 +93,7 @@ from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration from .types.user import User as UserPayload - from .ui.view import BaseView + from .ui.view import BaseView, DesignerView from .user import User MR = TypeVar("MR", bound="MessageReference") @@ -2345,6 +2345,35 @@ def get_component(self, id: str | int) -> Component | None: return component return None + def get_view( + self, cls: BaseView | None = None, force: bool = False + ) -> DesignerView | BaseView | None: + """Retrieve this message's view from the ViewStore. If there is no stored view, a new view will be returned if :attr:`components` is not empty. + + Parameters + ---------- + cls + The class that will be used to generate the new view. + By default, this is :class:`discord.ui.DesignerView`. Should a custom + class be provided, it must inherit from :class:`discord.ui.BaseView` + and properly implement ``from_message``. + force: :class:`bool` + When set to ``True``, this will ignore the ViewStore and forcibly create a new view. + + Returns + ------- + Optional[Union[:class:`discord.ui.DesignerView`, :class:`discord.ui.BaseView`]] + The view belonging to this message, if it exists or there are components available to create a new view. + """ + v = self._state.get_message_view(self.id) if not force else None + if not v and self.components: + if not cls: + from .ui.view import DesignerView + + cls = DesignerView + v = cls.from_message(self) + return v + class PartialMessage(Hashable): """Represents a partial message to aid with working messages when only diff --git a/discord/state.py b/discord/state.py index 574c973c52..f4e5c19660 100644 --- a/discord/state.py +++ b/discord/state.py @@ -418,6 +418,9 @@ def store_view(self, view: BaseView, message_id: int | None = None) -> None: def purge_message_view(self, message_id: int) -> None: self._view_store.remove_message_view(message_id) + def get_message_view(self, message_id: int) -> BaseView | None: + return self._view_store.get_message_view(message_id) + def store_modal(self, modal: BaseModal, message_id: int) -> None: self._modal_store.add_modal(modal, message_id) diff --git a/discord/types/components.py b/discord/types/components.py index af567b6566..37f2c7ab21 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -47,6 +47,12 @@ class BaseComponent(TypedDict): id: NotRequired[int] +class Modal(TypedDict): + custom_id: str + title: str + components: list[AllowedModalComponents] + + class ActionRow(BaseComponent): type: Literal[1] components: list[AllowedActionRowComponents] @@ -254,3 +260,9 @@ class CheckboxComponent(BaseComponent): CheckboxComponent, CheckboxGroupComponent, ] + +AllowedModalComponents = Union[ + ActionRow, + LabelComponent, + TextDisplayComponent, +] diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index a947f33f1e..79e7d7dbdc 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -26,13 +26,14 @@ from collections.abc import Sequence from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, TypeVar, overload +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Literal, TypeVar, overload from ..components import ActionRow as ActionRowComponent from ..components import SelectDefaultValue, SelectOption, _component_factory from ..enums import ButtonStyle, ChannelType, ComponentType from ..utils import find, get from .button import Button +from .core import _item_getter from .file import File from .item import ItemCallbackType, ViewItem from .select import Select @@ -62,6 +63,13 @@ class ActionRow(ViewItem[V]): .. versionadded:: 2.7 + .. container:: operations + + .. describe:: len(x) + + Returns the total count of all items in this row. + This includes the row itself, counting towards Discord's component limits. + Parameters ---------- *items: :class:`ViewItem` @@ -107,6 +115,9 @@ def __init__( for i in items: self.add_item(i) + def __len__(self) -> int: + return len(self.children) + 1 + @property def items(self) -> list[ViewItem]: return self.children @@ -134,19 +145,37 @@ def _generate_underlying(self, id: int | None = None) -> ActionRowComponent: row.children.append(i._generate_underlying()) return row - def add_item(self, item: ViewItem) -> Self: + def add_item( + self, + item: ViewItem, + *, + index: int | None = None, + before: ViewItem[V] | str | int | None = None, + after: ViewItem[V] | str | int | None = None, + ) -> Self: """Adds an item to the action row. Parameters ---------- item: :class:`ViewItem` The item to add to the action row. + index: Optional[class:`int`] + Add the new item at the specific index of :attr:`children`. Same behavior as Python's :func:`~list.insert`. + before: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **before** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + after: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **after** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. Raises ------ TypeError A :class:`ViewItem` was not passed. + ValueError + Maximum number of items has been exceeded (5 buttons or 1 select), + or a searched item could not be found in the row. """ + if sum(x is not None for x in (before, after, index)) > 1: + raise ValueError("Can only specify one of before, after, and index.") if not isinstance(item, (Select, Button)): raise TypeError(f"expected Select or Button, not {item.__class__!r}") @@ -155,8 +184,29 @@ def add_item(self, item: ViewItem) -> Self: if self.width + item.width > 5: raise ValueError(f"Not enough space left on this ActionRow") - item.parent = self + if before is not None or after is not None: + try: + ref = before or after or 0 + if isinstance(ref, (int, str)): + ref = self.get_item(ref) + i = self.children.index(ref) + item.parent = self + if before: + self.children.insert(i, item) + else: + self.children.insert(i + 1, item) + except ValueError: + raise ValueError(f"Could not find {before or after} in row.") + self._underlying = self._generate_underlying() + return self + + elif index is not None: + item.parent = self + self.children.insert(index, item) + self._underlying = self._generate_underlying() + return self + item.parent = self self.children.append(item) self._add_component_from_item(item) return self @@ -172,6 +222,8 @@ def remove_item(self, item: ViewItem | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) + if not item: + return self try: self.children.remove(item) item.parent = None @@ -179,24 +231,63 @@ def remove_item(self, item: ViewItem | str | int) -> Self: pass return self - def get_item(self, id: str | int) -> ViewItem | None: - """Get an item from this action row. Roughly equivalent to `utils.get(row.children, ...)`. + def replace_item( + self, original_item: ViewItem | str | int, new_item: ViewItem + ) -> Self: + """Directly replace an item in this row. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + original_item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to replace in the row. + new_item: :class:`ViewItem` + The new item to insert into the row. + """ + + if not isinstance(new_item, (Select, Button)): + raise TypeError(f"expected Select or Button, not {new_item.__class__!r}") + + if isinstance(original_item, (str, int)): + original_item = self.get_item(original_item) + if not original_item: + raise ValueError(f"Could not find {original_item} in row.") + try: + i = self.children.index(original_item) + new_item.parent = self + self.children[i] = new_item + original_item.parent = None + except ValueError: + raise ValueError(f"Could not find {original_item} in row.") + return self + + def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None: + r"""Get an item from this action row. Roughly equivalent to `utils.get(row.children, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. + If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. + To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. Parameters ---------- id: Union[:class:`str`, :class:`int`] The id or custom_id of the item to get. + \*\*attrs + Keyword arguments that denote attributes to search with. Returns ------- Optional[:class:`ViewItem`] The item with the matching ``id`` or ``custom_id`` if it exists. """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - child = find(lambda i: getattr(i, attr, None) == id, self.children) + child = None + if id: + attr = "id" if isinstance(id, int) else "custom_id" + if attrs: + attrs[attr] = id + child = find(lambda i: getattr(i, attr, None) == id, self.children) + elif attrs: + child = _item_getter(self.children, **attrs) + return child def add_button( diff --git a/discord/ui/container.py b/discord/ui/container.py index 0ebd72a2b4..43c03456d2 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, TypeVar +from typing import TYPE_CHECKING, Any, Iterator, TypeVar from ..colour import Colour from ..components import Container as ContainerComponent @@ -33,6 +33,7 @@ from ..utils import find, get from .action_row import ActionRow from .button import Button +from .core import _item_getter from .file import File from .item import ItemCallbackType, ViewItem from .media_gallery import MediaGallery @@ -68,6 +69,13 @@ class Container(ViewItem[V]): .. versionadded:: 2.7 + .. container:: operations + + .. describe:: len(x) + + Returns the total count of all items in this container. + This includes the container itself, counting towards Discord's component limits. + Parameters ---------- *items: :class:`ViewItem` @@ -120,6 +128,9 @@ def __init__( for i in items: self.add_item(i) + def __len__(self) -> int: + return sum(len(i) for i in self.items) + 1 + def _add_component_from_item(self, item: ViewItem): self.underlying.components.append(item._generate_underlying()) @@ -146,19 +157,40 @@ def _generate_underlying( container.components.append(i._generate_underlying()) return container - def add_item(self, item: ViewItem) -> Self: + def add_item( + self, + item: ViewItem, + *, + index: int | None = None, + before: ViewItem[V] | str | int | None = None, + after: ViewItem[V] | str | int | None = None, + into: ViewItem[V] | str | int | None = None, + ) -> Self: """Adds an item to the container. Parameters ---------- item: :class:`ViewItem` The item to add to the container. + index: Optional[class:`int`] + Add the new item at the specific index of :attr:`items`. Same behavior as Python's :func:`~list.insert`. + before: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **before** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + after: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **after** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + into: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **into** the specified item. This would be equivalent to `into.add_item(item)`, where `into` is a :class:`ViewItem`. + If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. Raises ------ TypeError A :class:`ViewItem` was not passed. + ValueError + A searched item could not be found in the container. """ + if sum(x is not None for x in (before, after, index)) > 1: + raise ValueError("Can only specify one of before, after, and index.") if not isinstance(item, ViewItem): raise TypeError(f"expected ViewItem not {item.__class__!r}") @@ -167,11 +199,41 @@ def add_item(self, item: ViewItem) -> Self: raise TypeError( f"{item.__class__!r} cannot be added directly. Use ActionRow instead." ) - - item.parent = self - - self.items.append(item) - self._add_component_from_item(item) + if into and isinstance(into, (str, int)): + parent = self.get_item(into) + if not parent: + raise ValueError(f"Could not find {into} in container.") + else: + parent = into or self + + if before or after: + ref = before or after or 0 + if isinstance(ref, (int, str)): + ref = parent.get_item(ref) + try: + if ref.parent is parent: + i = parent.items.index(ref) + item.parent = parent + if before: + parent.items.insert(i, item) + else: + parent.items.insert(i + 1, item) + else: + ref.parent.add_item(item, before=before, after=after) + except (ValueError, AttributeError): + raise ValueError(f"Could not find {before or after} in container.") + self._underlying = self._generate_underlying() + return self + + elif index is not None: + item.parent = parent + parent.items.insert(index, item) + self._underlying = self._generate_underlying() + return self + + item.parent = parent + parent.items.append(item) + parent._add_component_from_item(item) return self def remove_item(self, item: ViewItem | str | int) -> Self: @@ -185,6 +247,8 @@ def remove_item(self, item: ViewItem | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) + if not item: + return self try: if item.parent is self: self.items.remove(item) @@ -195,30 +259,68 @@ def remove_item(self, item: ViewItem | str | int) -> Self: pass return self - def get_item(self, id: str | int) -> ViewItem | None: - """Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`. + def replace_item( + self, original_item: ViewItem | str | int, new_item: ViewItem + ) -> Self: + """Directly replace an item in this container. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + original_item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to replace in the container. + new_item: :class:`ViewItem` + The new item to insert into the container. + """ + + if isinstance(original_item, (str, int)): + original_item = self.get_item(original_item) + if not original_item: + raise ValueError(f"Could not find {original_item} in container.") + try: + if original_item.parent is self: + i = self.items.index(original_item) + new_item.parent = self + self.items[i] = new_item + original_item.parent = None + else: + original_item.parent.replace_item(original_item, new_item) + except ValueError: + raise ValueError(f"Could not find {original_item} in container.") + return self + + def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None: + r"""Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. This method will also search for nested items. + If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. + To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. Parameters ---------- - id: Union[:class:`str`, :class:`int`] + id: Optional[Union[:class:`str`, :class:`int`]] The id or custom_id of the item to get. + \*\*attrs + Keyword arguments that denote attributes to search with. Returns ------- Optional[:class:`ViewItem`] The item with the matching ``id`` or ``custom_id`` if it exists. """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - child = find(lambda i: getattr(i, attr, None) == id, self.items) - if not child: - for i in self.items: - if hasattr(i, "get_item"): - if child := i.get_item(id): - return child + child = None + if id: + attr = "id" if isinstance(id, int) else "custom_id" + if attrs: + attrs[attr] = id + child = find(lambda i: getattr(i, attr, None) == id, self.items) + if not child: + for i in self.items: + if hasattr(i, "get_item"): + if child := i.get_item(id): + return child + elif attrs: + child = _item_getter(self.items, **attrs) return child def add_row( diff --git a/discord/ui/core.py b/discord/ui/core.py index 21bb16871a..bd75571b7e 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -27,6 +27,7 @@ import asyncio import time from itertools import groupby +from operator import attrgetter from typing import TYPE_CHECKING, Any, Callable from ..utils import find, get @@ -41,11 +42,36 @@ from .view import View +def _item_getter(iterable, **attrs) -> Item | None: + _all = all + attrget = attrgetter + for i in iterable: + converted = [ + (attrget(attr.replace("__", ".")), value) for attr, value in attrs.items() + ] + try: + if _all(pred(i) == value for pred, value in converted): + return i + except AttributeError: + pass + if hasattr(i, "get_item"): + if child := i.get_item(**attrs): + return child + return None + + class ItemInterface: """The base structure for classes that contain :class:`~discord.ui.Item`. .. versionadded:: 2.7 + .. container:: operations + + .. describe:: len(x) + + Returns the total count of all items in this interface. + This includes items that contain other items, which count towards Discord's component limits. + Parameters ---------- *items: :class:`Item` @@ -86,6 +112,12 @@ def __init__( def __repr__(self) -> str: return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" + def __len__(self) -> int: + return sum(len(i) for i in self.children) + + def __bool__(self) -> bool: + return True + async def _timeout_task_impl(self) -> None: while True: # Guard just in case someone changes the value of the timeout at runtime @@ -115,30 +147,54 @@ def _dispatch_timeout(self): def to_components(self) -> list[dict[str, Any]]: return [item.to_component_dict() for item in self.children] - def get_item(self, custom_id: str | int) -> Item | None: - """Gets an item from this structure. Roughly equal to `utils.get(self.children, ...)`. + def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | None: + r"""Gets an item from this structure. Roughly equal to `utils.get(self.children, **attrs)`. If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. This method will also search nested items. + If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. + To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. + + Examples + --------- + + Basic usage: + + .. code-block:: python3 + + container = my_view.get(1234) + + Attribute matching: + + .. code-block:: python3 + + button = my_view.get(label='Click me!', style=discord.ButtonStyle.danger) Parameters ---------- - custom_id: Union[:class:`str`, :class:`int`] + custom_id: Optional[Union[:class:`str`, :class:`int`]] The id of the item to get + \*\*attrs + Keyword arguments that denote attributes to search with. Returns ------- Optional[:class:`Item`] - The item with the matching ``custom_id`` or ``id`` if it exists. + The item with the matching ``custom_id``, ``id``, or ``attrs`` if it exists. """ - if not custom_id: - return None - attr = "id" if isinstance(custom_id, int) else "custom_id" - child = find(lambda i: getattr(i, attr, None) == custom_id, self.children) - if not child: - for i in self.children: - if hasattr(i, "get_item"): - if child := i.get_item(custom_id): - return child + child = None + if custom_id: + attr = "id" if isinstance(custom_id, int) else "custom_id" + if attrs: + attrs[attr] = custom_id + child = find(lambda i: getattr(i, attr, None) == custom_id, self.children) + if not child: + for i in self.children: + if hasattr(i, "get_item"): + if child := i.get_item(custom_id): + return child + elif attrs: + child = _item_getter(self.children, **attrs) + return child def add_item(self, item: Item) -> Self: diff --git a/discord/ui/item.py b/discord/ui/item.py index 85132558bd..8da2e6ff02 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -58,6 +58,13 @@ class Item(Generic[T]): .. versionchanged:: 2.7 Now used as base class for :class:`ViewItem` and :class:`ModalItem`. + + .. container:: operations + + .. describe:: len(x) + + Returns how much this item counts towards Discord's component limits. + This is 1 for all items, plus 1 for each child item. """ __item_repr_attributes__: tuple[str, ...] = ("id",) @@ -67,6 +74,12 @@ def __init__(self): self._provided_custom_id: bool = False self.parent: Item | ItemInterface | None = None + def __len__(self) -> int: + return 1 + + def __bool__(self) -> bool: + return True + def to_component_dict(self) -> dict[str, Any]: if not self.underlying: raise NotImplementedError diff --git a/discord/ui/label.py b/discord/ui/label.py index 69c519f0cd..f8aad53735 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -80,10 +80,10 @@ class Label(ModalItem[M]): Parameters ---------- - item: :class:`ModalItem` - The initial item attached to this label. label: :class:`str` The label text. Must be 45 characters or fewer. + item: :class:`ModalItem` + The initial item attached to this label. description: Optional[:class:`str`] The description for this label. Must be 100 characters or fewer. id: Optional[:class:`int`] diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 98ad8c965a..d6f9c2e8be 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -26,6 +26,7 @@ from typing import TYPE_CHECKING, TypeVar +from ..asset import AssetMixin from ..components import MediaGallery as MediaGalleryComponent from ..components import MediaGalleryItem from ..enums import ComponentType @@ -36,6 +37,8 @@ if TYPE_CHECKING: from typing_extensions import Self + from ..file import File + from ..message import Attachment from ..types.components import MediaGalleryComponent as MediaGalleryComponentPayload from .view import DesignerView @@ -55,11 +58,6 @@ class MediaGallery(ViewItem[V]): The initial items contained in this gallery, up to 10. id: Optional[:class:`int`] The gallery's ID. - - Attributes - ---------- - items: List[:class:`~discord.MediaGalleryItem`] - The list of media items in this gallery. """ __item_repr_attributes__: tuple[str, ...] = ( @@ -114,7 +112,7 @@ def append_item(self, item: MediaGalleryItem) -> Self: """ if len(self.items) >= 10: - raise ValueError("maximum number of items exceeded") + raise ValueError("maximum number of items exceeded (10)") if not isinstance(item, MediaGalleryItem): raise TypeError(f"expected MediaGalleryItem not {item.__class__!r}") @@ -146,9 +144,6 @@ def add_item( Maximum number of items has been exceeded (10). """ - if len(self.items) >= 10: - raise ValueError("maximum number of items exceeded") - item = MediaGalleryItem(url, description=description, spoiler=spoiler) return self.append_item(item) @@ -168,10 +163,79 @@ def remove_item(self, index: int) -> Self: pass return self + def replace_item(self, index: int, new_item: MediaGalleryItem) -> Self: + """Directly replace an item in this gallery by index. + + Parameters + ---------- + index: :class:`int` + The index of the item to replace in this gallery. + new_item: :class:`MediaGalleryItem` + The new item to insert into the gallery. + """ + + if not isinstance(new_item, MediaGalleryItem): + raise TypeError(f"expected MediaGalleryItem not {new_item.__class__!r}") + self._underlying.items[index] = new_item + return self + def to_component_dict(self) -> MediaGalleryComponentPayload: self._underlying = self._generate_underlying() return super().to_component_dict() + @classmethod + def from_assets( + cls: type[M], + *assets: Attachment | AssetMixin, + id: int | None = None, + new: bool = False, + ) -> M: + r"""Converts a list of :class:`discord.Attachment` or :class:`discord.Asset` to a gallery. + + Parameters + ---------- + \*assets: Union[:class:`discord.Attachment`, :class:`discord.AssetMixin`] + An argument list of assets to add to the gallery. These can be attachments, emojis, stickers, avatars, or anything other model served by Discord. + id: Optional[:class:`int`] + The gallery's ID. + new: :class:`bool` + If ``True``, uses local ``attachment://`` URLs instead of the CDN URLs. Ideal when reuploading assets. + """ + gallery = cls(id=id) + for a in assets: + name = a.cdn_name if isinstance(a, AssetMixin) else a.filename + url = f"attachment://{name}" if new else a.url + gallery.add_item( + url=url, + description=getattr(a, "description", None), + spoiler=getattr(a, "spoiler", False), + ) + return gallery + + @classmethod + def from_files(cls: type[M], *files: File, id: int | None = None) -> M: + r"""Converts a list of local :class:`discord.File` to a gallery. + + Parameters + ---------- + \*files: :class:`discord.File` + An argument list of :class:`discord.File` to add to the gallery. + id: Optional[:class:`int`] + The gallery's ID. + """ + gallery = cls(id=id) + for f in files: + gallery.add_item( + url=f"attachment://{f.filename}", + description=f.description, + spoiler=f.spoiler, + ) + return gallery + + async def to_files(self) -> list[File]: + """Converts this gallery to a list of :class:`discord.File` for use with :meth:`abc.Messageable.send`.""" + return [await f.media.to_file() for f in self.items] + @classmethod def from_component(cls: type[M], component: MediaGalleryComponent) -> M: return cls(*component.items, id=component.id) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 6bb3a21d6a..00a0e13e23 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -32,6 +32,7 @@ from itertools import groupby from typing import TYPE_CHECKING, Any, Iterator, TypeVar +from ..components import _component_factory from ..enums import ComponentType from ..utils import find from .core import ItemInterface @@ -40,6 +41,7 @@ from .label import Label from .select import Select from .text_display import TextDisplay +from .view import _component_to_item __all__ = ( "BaseModal", @@ -55,6 +57,7 @@ from ..interactions import Interaction from ..state import ConnectionState from ..types.components import Component as ComponentPayload + from ..types.components import Modal as ModalPayload M = TypeVar("M", bound="Modal", covariant=True) @@ -431,6 +434,39 @@ def children(self, value: list[ModalItem]): ) self._children = value + @classmethod + def from_dict( + cls, + data: ModalPayload, + /, + *, + timeout: float | None = None, + ) -> DesignerModal: + """Converts a modal dictionary into a :class:`DesignerModal`. + + Parameters + ---------- + data: ModalPayload + The dict representing a modal + timeout: Optional[:class:`float`] + The timeout of the converted modal. + + Returns + ------- + :class:`DesignerModal` + The converted view. This always returns a :class:`DesignerModal` and not + one of its subclasses. + """ + modal = DesignerModal( + title=data.get("title"), + custom_id=data.get("custom_id", None), + timeout=timeout, + ) + components = [_component_factory(d) for d in data.get("components", [])] + for component in components: + modal.add_item(_component_to_item(component)) + return modal + def add_item(self, item: ModalItem) -> Self: """Adds a component to the modal. @@ -448,6 +484,49 @@ def add_item(self, item: ModalItem) -> Self: super().add_item(item) return self + def add_label( + self, + label: str, + item: ModalItem = None, + *, + description: str | None = None, + id: int | None = None, + ) -> Self: + """Adds a :class:`Label` to the modal. + + To append a pre-existing :class:`Label`, use :meth:`add_item` instead. + + Parameters + ---------- + label: :class:`str` + The label text. Must be 45 characters or fewer. + item: :class:`ModalItem` + The initial item attached to this label. + description: Optional[:class:`str`] + The description for this label. Must be 100 characters or fewer. + id: Optional[:class:`int`] + The label's ID. + """ + + label = Label(label, item=item, description=description, id=id) + + return self.add_item(label) + + def add_text(self, content: str, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` to the modal. + + Parameters + ---------- + content: :class:`str` + The content of the TextDisplay + id: Optional[:class:`int`] + The text display's ID. + """ + + text = TextDisplay(content, id=id) + + return self.add_item(text) + def refresh(self, interaction: Interaction, data: list[ComponentPayload]): for component, child in zip(data, self.children): child.refresh_from_modal(interaction, component) diff --git a/discord/ui/radio_group.py b/discord/ui/radio_group.py index 7a01501776..9297991a68 100644 --- a/discord/ui/radio_group.py +++ b/discord/ui/radio_group.py @@ -47,7 +47,7 @@ class RadioGroup(ModalItem): .. versionadded:: 2.8 - Attributes + Parameters ---------- custom_id: Optional[:class:`str`] The ID of the radio group that gets received during an interaction. diff --git a/discord/ui/section.py b/discord/ui/section.py index 9b733999b7..5b311635e7 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -25,13 +25,14 @@ from __future__ import annotations from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar from ..components import Section as SectionComponent from ..components import _component_factory from ..enums import ComponentType from ..utils import find, get from .button import Button +from .core import _item_getter from .item import ItemCallbackType, ViewItem from .text_display import TextDisplay from .thumbnail import Thumbnail @@ -54,6 +55,13 @@ class Section(ViewItem[V]): .. versionadded:: 2.7 + .. container:: operations + + .. describe:: len(x) + + Returns the total count of all items in this section. + This includes the section itself, counting towards Discord's component limits. + Parameters ---------- *items: :class:`ViewItem` @@ -115,6 +123,10 @@ def __init__( for i in items: self.add_item(i) + def __len__(self) -> int: + r = sum(len(i) for i in self.items) + 1 + return (r + 1) if self.accessory else r + def _add_component_from_item(self, item: ViewItem): self.underlying.components.append(item.underlying) @@ -137,28 +149,66 @@ def _generate_underlying(self, id: int | None = None) -> SectionComponent: section.accessory = self.accessory._generate_underlying() return section - def add_item(self, item: ViewItem) -> Self: + def add_item( + self, + item: ViewItem, + *, + index: int | None = None, + before: ViewItem[V] | str | int | None = None, + after: ViewItem[V] | str | int | None = None, + ) -> Self: """Adds an item to the section. Parameters ---------- item: :class:`ViewItem` The item to add to the section. + index: Optional[class:`int`] + Add the new item at the specific index of :attr:`items`. Same behavior as Python's :func:`~list.insert`. + before: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **before** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + after: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **after** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. Raises ------ TypeError An :class:`ViewItem` was not passed. ValueError - Maximum number of items has been exceeded (3). + Maximum number of items has been exceeded (3), + or a searched item could not be found in the section. """ + if sum(x is not None for x in (before, after, index)) > 1: + raise ValueError("Can only specify one of before, after, and index.") if len(self.items) >= 3: - raise ValueError("maximum number of children exceeded") + raise ValueError("maximum number of children exceeded (3)") if not isinstance(item, ViewItem): raise TypeError(f"expected ViewItem not {item.__class__!r}") + if before is not None or after is not None: + ref = before or after or 0 + if isinstance(ref, (int, str)): + ref = self.get_item(ref) + try: + i = self.items.index(ref) + item.parent = self + if before: + self.items.insert(i, item) + else: + self.items.insert(i + 1, item) + except ValueError: + raise ValueError(f"Could not find {before or after} in section.") + self._underlying = self._generate_underlying() + return self + + elif index is not None: + item.parent = self + self.items.insert(index, item) + self._underlying = self._generate_underlying() + return self + item.parent = self self.items.append(item) self._add_component_from_item(item) @@ -176,6 +226,8 @@ def remove_item(self, item: ViewItem | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) + if not item: + return self try: if item is self.accessory: self.accessory = None @@ -186,26 +238,77 @@ def remove_item(self, item: ViewItem | str | int) -> Self: pass return self - def get_item(self, id: int | str) -> ViewItem | None: - """Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`. + def replace_item( + self, original_item: ViewItem | str | int, new_item: ViewItem + ) -> Self: + """Directly replace an item in this section. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + original_item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to replace in the section. + new_item: :class:`ViewItem` + The new item to insert into the section. + """ + + if not original_item: + raise TypeError( + f"expected original_item to be a valid ViewItem, str, or int, not {new_item.__class__!r}" + ) + if not isinstance(new_item, ViewItem): + raise TypeError( + f"expected new_item to be ViewItem, not {new_item.__class__!r}" + ) + + if isinstance(original_item, (str, int)): + original_item = self.get_item(original_item) + if not original_item: + raise ValueError(f"Could not find {original_item} in section.") + try: + if original_item is self.accessory: + self.accessory = new_item + else: + i = self.items.index(original_item) + self.items[i] = new_item + original_item.parent = None + new_item.parent = self + except ValueError: + raise ValueError(f"Could not find {original_item} in section.") + return self + + def get_item(self, id: int | str | None = None, **attrs: Any) -> ViewItem | None: + r"""Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`. If an ``int`` is provided, it will be retrieved by ``id``, otherwise it will check the accessory's ``custom_id``. + If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. + To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. Parameters ---------- id: Union[:class:`str`, :class:`int`] The id or custom_id of the item to get. + \*\*attrs + Keyword arguments that denote attributes to search with. Returns ------- Optional[:class:`ViewItem`] The item with the matching ``id`` if it exists. """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - if self.accessory and id == getattr(self.accessory, attr, None): - return self.accessory - child = find(lambda i: getattr(i, attr, None) == id, self.items) + child = None + iterr = self.items[:] + if self.accessory: + iterr.append(self.accessory) + if id: + attr = "id" if isinstance(id, int) else "custom_id" + if attrs: + attrs[attr] = id + if self.accessory and id == getattr(self.accessory, attr, None): + return self.accessory + child = find(lambda i: getattr(i, attr, None) == id, self.items) + elif attrs: + child = _item_getter(iterr, **attrs) + return child def add_text(self, content: str, *, id: int | None = None) -> Self: diff --git a/discord/ui/view.py b/discord/ui/view.py index 19a12ec79f..f374298d21 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -257,7 +257,7 @@ def add_item(self, item: ViewItem[V]) -> Self: Maximum number of children has been exceeded """ - if len(self.children) >= self.MAX_ITEMS: + if len(self) >= self.MAX_ITEMS: raise ValueError("maximum number of children exceeded") if not isinstance(item, ViewItem): @@ -284,6 +284,8 @@ def remove_item(self, item: ViewItem[V] | int | str) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) + if not item: + return self try: if item.parent is self: self.children.remove(item) @@ -546,6 +548,15 @@ def message(self): def message(self, value): self._message = value + @classmethod + def from_message( + cls, message: Message, /, *, timeout: float | None = 180.0 + ) -> BaseView: + view = cls(timeout=timeout) + for component in message.components: + view.add_item(_component_to_item(component)) + return view + class View(BaseView): """Represents a legacy UI view for V1 components :class:`~discord.ui.Button` and :class:`~discord.ui.Select`. @@ -856,7 +867,7 @@ def items(self, value: list[ViewItem[V]]) -> None: @classmethod def from_message( cls, message: Message, /, *, timeout: float | None = 180.0 - ) -> View: + ) -> DesignerView: """Converts a message's components into a :class:`DesignerView`. The :attr:`.Message.components` of a message are read-only @@ -874,7 +885,7 @@ def from_message( Returns ------- :class:`View` - The converted view. This always returns a :class:`View` and not + The converted view. This always returns a :class:`DesignerView` and not one of its subclasses. """ view = DesignerView(timeout=timeout) @@ -889,7 +900,7 @@ def from_dict( /, *, timeout: float | None = 180.0, - ) -> View: + ) -> DesignerView: """Converts a list of component dicts into a :class:`DesignerView`. Parameters @@ -902,7 +913,7 @@ def from_dict( Returns ------- :class:`DesignerView` - The converted view. This always returns a :class:`View` and not + The converted view. This always returns a :class:`DesignerView` and not one of its subclasses. """ view = DesignerView(timeout=timeout) @@ -911,30 +922,293 @@ def from_dict( view.add_item(_component_to_item(component)) return view - def add_item(self, item: ViewItem[V]) -> Self: + def add_item( + self, + item: ViewItem[V], + *, + index: int | None = None, + before: ViewItem[V] | str | int | None = None, + after: ViewItem[V] | str | int | None = None, + into: ViewItem[V] | str | int | None = None, + ) -> Self: """Adds an item to the view. + .. warning:: + + You may specify only **one** of ``index``, ``before``, & ``after``. ``into`` will work together with those parameters. + + .. versionchanged:: 2.7.1 + Added new parameters ``index``, ``before``, ``after``, & ``into``. + Parameters ---------- item: :class:`ViewItem` The item to add to the view. + index: Optional[class:`int`] + Add the new item at the specific index of :attr:`children`. Same behavior as Python's :func:`~list.insert`. + before: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **before** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + after: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **after** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + into: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **into** the specified item. This would be equivalent to `into.add_item(item)`, where `into` is a :class:`ViewItem`. + If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. Raises ------ TypeError - An :class:`ViewItem` was not passed. + A :class:`ViewItem` was not passed. ValueError - Maximum number of items has been exceeded (40) + Maximum number of items has been exceeded (40), + or a searched item could not be found in the view. """ + if sum(x is not None for x in (before, after, index)) > 1: + raise ValueError("Can only specify one of before, after, and index.") + + if len(self) + len(item) > self.MAX_ITEMS: + raise ValueError("maximum number of children exceeded (40)") - if isinstance(item._underlying, (SelectComponent, ButtonComponent)): + if not isinstance(item, ViewItem): + raise TypeError(f"expected item to be ViewItem, not {item.__class__!r}") + + if isinstance(item.underlying, (SelectComponent, ButtonComponent)): raise ValueError( f"cannot add Select or Button to DesignerView directly. Use ActionRow instead." ) + if into and isinstance(into, (str, int)): + parent = self.get_item(into) + if not parent: + raise ValueError(f"Could not find {into} in view.") + else: + parent = into or self - super().add_item(item) + if before is not None or after is not None: + ref = before or after or 0 + if isinstance(ref, (int, str)): + ref = parent.get_item(ref) + try: + if ref.parent is parent: + i = parent.items.index(ref) + item.parent = parent + if before: + parent.items.insert(i, item) + else: + parent.items.insert(i + 1, item) + else: + if isinstance(ref.parent.underlying, ContainerComponent): + ref.parent.add_item(item, before=before, after=after, into=into) + else: + ref.parent.add_item(item, before=before, after=after) + except (ValueError, AttributeError): + raise ValueError(f"Could not find {before or after} in view.") + return self + + elif index is not None: + item.parent = parent + parent.items.insert(index, item) + return self + + item.parent = parent + parent.items.append(item) return self + def replace_item( + self, original_item: ViewItem[V] | str | int, new_item: ViewItem[V] + ) -> Self: + """Directly replace an item in this view. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + original_item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to replace in the view. + new_item: :class:`ViewItem` + The new item to insert into the view. + + Returns + ------- + :class:`BaseView` + The view instance. + """ + + if not original_item: + raise TypeError( + f"expected original_item to be a valid ViewItem, str, or int, not {new_item.__class__!r}" + ) + if not isinstance(new_item, ViewItem): + raise TypeError( + f"expected new_item to be ViewItem, not {new_item.__class__!r}" + ) + + if isinstance(original_item, (str, int)): + original_item = self.get_item(original_item) + if not original_item: + raise ValueError(f"Could not find {original_item} in view.") + try: + if original_item.parent is self: + i = self.children.index(original_item) + new_item.parent = self + self.children[i] = new_item + original_item.parent = None + else: + original_item.parent.replace_item(original_item, new_item) + except ValueError: + raise ValueError(f"Could not find {original_item} in view.") + return self + + def add_row( + self, + *items: ViewItem[V], + id: int | None = None, + ) -> Self: + """Adds an :class:`ActionRow` to the view. + + To append a pre-existing :class:`ActionRow`, use :meth:`add_item` instead. + + Parameters + ---------- + *items: Union[:class:`Button`, :class:`Select`] + The items this action row contains. + id: Optiona[:class:`int`] + The action row's ID. + """ + from .action_row import ActionRow + + row = ActionRow(*items, id=id) + + return self.add_item(row) + + def add_container( + self, + *items: ViewItem[V], + id: int | None = None, + ) -> Self: + """Adds a :class:`Container` to the view. + + To append a pre-existing :class:`Container`, use the + :meth:`add_item` method, instead. + + Parameters + ---------- + *items: :class:`ViewItem` + The items contained in this container. + id: Optional[:class:`int`] + The container's ID. + """ + from .container import Container + + container = Container(*items, id=id) + + return self.add_item(container) + + def add_section( + self, + *items: ViewItem[V], + accessory: ViewItem[V], + id: int | None = None, + ) -> Self: + """Adds a :class:`Section` to the view. + + To append a pre-existing :class:`Section`, use the + :meth:`add_item` method, instead. + + Parameters + ---------- + *items: :class:`ViewItem` + The items contained in this section, up to 3. + Currently only supports :class:`~discord.ui.TextDisplay`. + accessory: Optional[:class:`ViewItem`] + The section's accessory. This is displayed in the top right of the section. + Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`. + id: Optional[:class:`int`] + The section's ID. + """ + from .section import Section + + section = Section(*items, accessory=accessory, id=id) + + return self.add_item(section) + + def add_text(self, content: str, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` to the view. + + Parameters + ---------- + content: :class:`str` + The content of the TextDisplay + id: Optiona[:class:`int`] + The text displays' ID. + """ + from .text_display import TextDisplay + + text = TextDisplay(content, id=id) + + return self.add_item(text) + + def add_gallery( + self, + *items: MediaGalleryItem, + id: int | None = None, + ) -> Self: + """Adds a :class:`MediaGallery` to the view. + + To append a pre-existing :class:`MediaGallery`, use :meth:`add_item` instead. + + Parameters + ---------- + *items: :class:`MediaGalleryItem` + The media this gallery contains. + id: Optiona[:class:`int`] + The gallery's ID. + """ + from .media_gallery import MediaGallery + + g = MediaGallery(*items, id=id) + + return self.add_item(g) + + def add_file(self, url: str, spoiler: bool = False, id: int | None = None) -> Self: + """Adds a :class:`File` to the view. + + Parameters + ---------- + url: :class:`str` + The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`. + spoiler: Optional[:class:`bool`] + Whether the file has the spoiler overlay. Defaults to ``False``. + id: Optiona[:class:`int`] + The file's ID. + """ + from .file import File + + f = File(url, spoiler=spoiler, id=id) + + return self.add_item(f) + + def add_separator( + self, + *, + divider: bool = True, + spacing: SeparatorSpacingSize = SeparatorSpacingSize.small, + id: int | None = None, + ) -> Self: + """Adds a :class:`Separator` to the container. + + Parameters + ---------- + divider: :class:`bool` + Whether the separator is a divider. Defaults to ``True``. + spacing: :class:`~discord.SeparatorSpacingSize` + The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`. + id: Optional[:class:`int`] + The separator's ID. + """ + from .separator import Separator + + s = Separator(divider=divider, spacing=spacing, id=id) + + return self.add_item(s) + def refresh(self, components: list[Component]): # Refreshes view data using discord's values # Assumes the components and items are identical @@ -974,7 +1248,7 @@ def persistent_views(self) -> Sequence[BaseView]: } return list(views.values()) - def __verify_integrity(self): + def __verify_integrity(self) -> None: to_remove: list[tuple[int, int | None, str]] = [] for k, (view, _) in self._views.items(): if view.is_finished(): @@ -983,7 +1257,7 @@ def __verify_integrity(self): for k in to_remove: del self._views[k] - def add_view(self, view: BaseView, message_id: int | None = None): + def add_view(self, view: BaseView, message_id: int | None = None) -> None: if not view._store: return self.__verify_integrity() @@ -999,7 +1273,7 @@ def add_view(self, view: BaseView, message_id: int | None = None): if message_id is not None: self._synced_message_views[message_id] = view - def remove_view(self, view: BaseView): + def remove_view(self, view: BaseView) -> None: for item in view.walk_children(): if item.is_storable(): self._views.pop((item.type.value, item.custom_id), None) # type: ignore @@ -1009,10 +1283,15 @@ def remove_view(self, view: BaseView): self.remove_message_view(key) break - def remove_message_view(self, message_id): + def remove_message_view(self, message_id: int) -> None: del self._synced_message_views[message_id] - def dispatch(self, component_type: int, custom_id: str, interaction: Interaction): + def get_message_view(self, message_id: int) -> BaseView | None: + return self._synced_message_views.get(message_id) + + def dispatch( + self, component_type: int, custom_id: str, interaction: Interaction + ) -> None: self.__verify_integrity() message_id: int | None = interaction.message and interaction.message.id key = (component_type, message_id, custom_id) @@ -1029,13 +1308,15 @@ def dispatch(self, component_type: int, custom_id: str, interaction: Interaction item.refresh_state(interaction) view._dispatch_item(item, interaction) - def is_message_tracked(self, message_id: int): + def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views def remove_message_tracking(self, message_id: int) -> BaseView | None: return self._synced_message_views.pop(message_id, None) - def update_from_message(self, message_id: int, components: list[ComponentPayload]): + def update_from_message( + self, message_id: int, components: list[ComponentPayload] + ) -> None: # pre-req: is_message_tracked == true view = self._synced_message_views[message_id] components = [_component_factory(d, state=self._state) for d in components]