diff --git a/discord/ui/button.py b/discord/ui/button.py index 220d55ded..777d31ce3 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -66,6 +66,12 @@ class Button(Item[V]): The label of the button, if any. emoji: Optional[:class:`PartialEmoji`] The emoji of the button, if available. + row: Optional[:class:`int`] + The relative row this button belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number cannot be negative or greater than 5. """ __item_repr_attributes__: Tuple[str, ...] = ( @@ -74,7 +80,7 @@ class Button(Item[V]): 'disabled', 'label', 'emoji', - 'group_id', + 'row', ) def __init__( @@ -86,7 +92,7 @@ class Button(Item[V]): custom_id: Optional[str] = None, url: Optional[str] = None, emoji: Optional[Union[str, PartialEmoji]] = None, - group: Optional[int] = None, + row: Optional[int] = None, ): super().__init__() if custom_id is not None and url is not None: @@ -110,7 +116,7 @@ class Button(Item[V]): style=style, emoji=emoji, ) - self.group_id = group + self.row = row @property def style(self) -> ButtonStyle: @@ -189,7 +195,7 @@ class Button(Item[V]): custom_id=button.custom_id, url=button.url, emoji=button.emoji, - group=None, + row=None, ) @property @@ -213,7 +219,7 @@ def button( disabled: bool = False, style: ButtonStyle = ButtonStyle.secondary, emoji: Optional[Union[str, PartialEmoji]] = None, - group: Optional[int] = None, + row: Optional[int] = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A decorator that attaches a button to a component. @@ -242,12 +248,12 @@ def button( Whether the button is disabled or not. Defaults to ``False``. emoji: Optional[Union[:class:`str`, :class:`PartialEmoji`]] The emoji of the button. This can be in string form or a :class:`PartialEmoji`. - group: Optional[:class:`int`] - The relative group this button belongs to. A Discord component can only have 5 - groups. By default, items are arranged automatically into those 5 groups. If you'd - like to control the relative positioning of the group then passing an index is advised. - For example, group=1 will show up before group=2. Defaults to ``None``, which is automatic - ordering. + row: Optional[:class:`int`] + The relative row this button belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number cannot be negative or greater than 5. """ def decorator(func: ItemCallbackType) -> ItemCallbackType: @@ -264,7 +270,7 @@ def button( 'disabled': disabled, 'label': label, 'emoji': emoji, - 'group': group, + 'row': row, } return func diff --git a/discord/ui/item.py b/discord/ui/item.py index e6892bf64..6744f12d5 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -50,11 +50,12 @@ class Item(Generic[V]): - :class:`discord.ui.Button` """ - __item_repr_attributes__: Tuple[str, ...] = ('group_id',) + __item_repr_attributes__: Tuple[str, ...] = ('row',) def __init__(self): self._view: Optional[V] = None - self.group_id: Optional[int] = None + self._row: Optional[int] = None + self._rendered_row: Optional[int] = None def to_component_dict(self) -> Dict[str, Any]: raise NotImplementedError @@ -80,6 +81,24 @@ class Item(Generic[V]): attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__) return f'<{self.__class__.__name__} {attrs}>' + @property + def row(self) -> Optional[int]: + return self._row + + @row.setter + def row(self, value: Optional[int]): + if value is None: + self._row = None + elif 5 > value >= 0: + self._row = value + else: + raise ValueError('row cannot be negative or greater than or equal to 5') + + @property + def width(self) -> int: + """:class:`int`: The width of the item.""" + return 1 + @property def view(self) -> Optional[V]: """Optional[:class:`View`]: The underlying view for this item.""" diff --git a/discord/ui/select.py b/discord/ui/select.py index cbbee3bc9..e37b55c0d 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -75,6 +75,12 @@ class Select(Item[V]): Defaults to 1 and must be between 1 and 25. options: List[:class:`discord.SelectOption`] A list of options that can be selected in this menu. + row: Optional[:class:`int`] + The relative row this select menu belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number cannot be negative or greater than 5. """ __item_repr_attributes__: Tuple[str, ...] = ( @@ -92,7 +98,7 @@ class Select(Item[V]): min_values: int = 1, max_values: int = 1, options: List[SelectOption] = MISSING, - group: Optional[int] = None, + row: Optional[int] = None, ) -> None: self._selected_values: List[str] = [] custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id @@ -105,7 +111,7 @@ class Select(Item[V]): max_values=max_values, options=options, ) - self.group_id = group + self.row = row @property def custom_id(self) -> str: @@ -229,6 +235,10 @@ class Select(Item[V]): """List[:class:`str`]: A list of values that have been selected by the user.""" return self._selected_values + @property + def width(self) -> int: + return 5 + def to_component_dict(self) -> SelectMenuPayload: return self._underlying.to_dict() @@ -247,7 +257,7 @@ class Select(Item[V]): min_values=component.min_values, max_values=component.max_values, options=component.options, - group=None, + row=None, ) @property @@ -265,7 +275,7 @@ def select( min_values: int = 1, max_values: int = 1, options: List[SelectOption] = MISSING, - group: Optional[int] = None, + row: Optional[int] = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A decorator that attaches a select menu to a component. @@ -281,12 +291,12 @@ def select( custom_id: :class:`str` The ID of the select menu that gets received during an interaction. It is recommended not to set this parameter to prevent conflicts. - group: Optional[:class:`int`] - The relative group this select menu belongs to. A Discord component can only have 5 - groups. By default, items are arranged automatically into those 5 groups. If you'd - like to control the relative positioning of the group then passing an index is advised. - For example, group=1 will show up before group=2. Defaults to ``None``, which is automatic - ordering. + row: Optional[:class:`int`] + The relative row this select menu belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number cannot be negative or greater than 5. min_values: :class:`int` The minimum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. @@ -305,7 +315,7 @@ def select( func.__discord_ui_model_kwargs__ = { 'placeholder': placeholder, 'custom_id': custom_id, - 'group': group, + 'row': row, 'min_values': min_values, 'max_values': max_values, 'options': options, diff --git a/discord/ui/view.py b/discord/ui/view.py index aa4d52b5b..a88828241 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -67,6 +67,47 @@ def _component_to_item(component: Component) -> Item: return Item.from_component(component) +class _ViewWeights: + __slots__ = ( + 'weights', + ) + + def __init__(self, children: List[Item]): + self.weights: List[int] = [0, 0, 0, 0, 0] + + key = lambda i: sys.maxsize if i.row is None else i.row + children = sorted(children, key=key) + for row, group in groupby(children, key=key): + for item in group: + self.add_item(item) + + def find_open_space(self, item: Item) -> int: + for index, weight in enumerate(self.weights): + if weight + item.width <= 5: + return index + + raise ValueError('could not find open space for item') + + def add_item(self, item: Item) -> None: + if item.row is not None: + total = self.weights[item.row] + item.width + if total > 5: + raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)') + self.weights[item.row] = total + item._rendered_row = item.row + else: + index = self.find_open_space(item) + self.weights[index] += item.width + item._rendered_row = index + + def remove_item(self, item: Item) -> None: + if item._rendered_row is not None: + self.weights[item._rendered_row] -= item.width + item._rendered_row = None + + def clear(self) -> None: + self.weights = [0, 0, 0, 0, 0] + class View: """Represents a UI view. @@ -112,6 +153,7 @@ class View: setattr(self, func.__name__, item) self.children.append(item) + self.__weights = _ViewWeights(self.children) loop = asyncio.get_running_loop() self.id = os.urandom(16).hex() self._cancel_callback: Optional[Callable[[View], None]] = None @@ -120,29 +162,21 @@ class View: def to_components(self) -> List[Dict[str, Any]]: def key(item: Item) -> int: - if item.group_id is None: - return sys.maxsize - return item.group_id + return item._rendered_row or 0 children = sorted(self.children, key=key) components: List[Dict[str, Any]] = [] for _, group in groupby(children, key=key): - group = list(group) - if len(group) <= 5: - components.append( - { - 'type': 1, - 'components': [item.to_component_dict() for item in group], - } - ) - else: - components.extend( - { - 'type': 1, - 'components': [item.to_component_dict() for item in group[index : index + 5]], - } - for index in range(0, len(group), 5) - ) + children = [item.to_component_dict() for item in group] + if not children: + continue + + components.append( + { + 'type': 1, + 'components': children, + } + ) return components @@ -165,7 +199,8 @@ class View: TypeError A :class:`Item` was not passed. ValueError - Maximum number of children has been exceeded (25). + Maximum number of children has been exceeded (25) + or the row the item is trying to be added to is full. """ if len(self.children) > 25: @@ -174,6 +209,8 @@ class View: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__!r}') + self.__weights.add_item(item) + item._view = self self.children.append(item) @@ -190,10 +227,13 @@ class View: self.children.remove(item) except ValueError: pass + else: + self.__weights.remove_item(item) def clear_items(self) -> None: """Removes all items from the view.""" self.children.clear() + self.__weights.clear() async def interaction_check(self, interaction: Interaction) -> bool: """|coro|