diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1c1d95ad..8ae36a1227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ These changes are available on the `master` branch, but have not yet been releas ### Added +- Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & + `MediaGallery` ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) - Added support for community invites. ([#3044](https://github.com/Pycord-Development/pycord/pull/3044)) - Added `Member.colours` and `Member.colors` properties. diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index a35256b511..18936ed1d5 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 @@ -914,6 +916,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 cf9a6ea27b..aeb28e82ab 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,31 @@ def get_component(self, id: str | int) -> Component | None: return component return None + def get_view(self, cls: BaseView | None = None) -> 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``. + + 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 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 873bbf1a5a..90755c6ba9 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: + 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/ui/action_row.py b/discord/ui/action_row.py index a947f33f1e..10d6f45fe8 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -179,6 +179,36 @@ def remove_item(self, item: ViewItem | str | int) -> Self: pass return self + 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) -> ViewItem | None: """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``. diff --git a/discord/ui/container.py b/discord/ui/container.py index cec6959667..4367873be7 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -195,6 +195,36 @@ def remove_item(self, item: ViewItem | str | int) -> Self: pass return self + 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) -> ViewItem | None: """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``. diff --git a/discord/ui/core.py b/discord/ui/core.py index 21bb16871a..2c00309710 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 @@ -115,30 +116,68 @@ 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: + if not (custom_id or attrs): 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: + child = None + if custom_id: + 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 + elif attrs: + _all = all + attrget = attrgetter for i in self.children: + 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: + pass if hasattr(i, "get_item"): - if child := i.get_item(custom_id): + if child := i.get_item(custom_id, **attrs): return child + return child def add_item(self, item: Item) -> Self: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 98ad8c965a..009517b384 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -168,6 +168,22 @@ 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 + ---------- + original_item: :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() diff --git a/discord/ui/section.py b/discord/ui/section.py index 9b733999b7..ddf2fed6a3 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -186,6 +186,39 @@ def remove_item(self, item: ViewItem | str | int) -> Self: pass return self + 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 isinstance(new_item, ViewItem): + raise TypeError(f"expected 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) -> ViewItem | None: """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``. diff --git a/discord/ui/view.py b/discord/ui/view.py index 281df6fb47..0c7be24381 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -546,6 +546,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`. @@ -910,13 +919,37 @@ 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 ------ @@ -925,8 +958,17 @@ def add_item(self, item: ViewItem[V]) -> Self: ValueError Maximum number of items has been exceeded (40) """ - - if isinstance(item._underlying, (SelectComponent, ButtonComponent)): + if ( + before + and after + or before + and (index is not None) + or after + and (index is not None) + ): + raise ValueError("Can only specify one of before, after, and index.") + + if isinstance(item.underlying, (SelectComponent, ButtonComponent)): raise ValueError( f"cannot add Select or Button to DesignerView directly. Use ActionRow instead." ) @@ -934,6 +976,198 @@ def add_item(self, item: ViewItem[V]) -> Self: super().add_item(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 isinstance(new_item, ViewItem): + raise TypeError(f"expected 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. + accessory: Optional[:class:`ViewItem`] + 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:`TextDisplay` 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 @@ -973,7 +1207,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(): @@ -982,7 +1216,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() @@ -998,7 +1232,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 @@ -1008,10 +1242,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) @@ -1028,13 +1267,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]