diff --git a/docs/framework_utils.rst b/docs/framework_utils.rst index 3ec698114..946ad9c80 100644 --- a/docs/framework_utils.rst +++ b/docs/framework_utils.rst @@ -35,7 +35,7 @@ Embed Helpers :members: :exclude-members: randomize_color -Reaction Menus +Menus ============== .. automodule:: redbot.core.utils.menus diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 3bfafaf35..03e336693 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -142,6 +142,7 @@ class Red( schema_version=0, datarequests__allow_user_requests=True, datarequests__user_requests_are_strict=True, + use_buttons=False, ) self._config.register_guild( @@ -1351,6 +1352,17 @@ class Red( global_setting = await self._config.embeds() return global_setting + async def use_buttons(self) -> bool: + """ + Determines whether the bot owner has enabled use of buttons instead of + reactions for basic menus. + + Returns + ------- + bool + """ + return await self._config.use_buttons() + async def is_owner(self, user: Union[discord.User, discord.Member], /) -> bool: """ Determines if the user should be considered a bot owner. diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 263d680d3..ee39dde4a 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -3621,6 +3621,31 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): else: await ctx.send(_("Server prefixes set.")) + @_set.command(name="usebuttons") + async def use_buttons(self, ctx: commands.Context, use_buttons: bool = None): + """ + Set a global bot variable for using buttons in menus. + + When enabled, all usage of cores menus API will use buttons instead of reactions. + + This defaults to False. + Using this without a setting will toggle. + + **Examples:** + - `[p]set usebuttons True` - Enables using buttons. + - `[p]helpset usebuttons` - Toggles the value. + + **Arguments:** + - `[use_buttons]` - Whether to use buttons. Leave blank to toggle. + """ + if use_buttons is None: + use_buttons = not await ctx.bot._config.use_buttons() + await ctx.bot._config.use_buttons.set(use_buttons) + if use_buttons: + await ctx.send(_("I will use buttons on basic menus.")) + else: + await ctx.send(_("I will not use buttons on basic menus.")) + @commands.group() @checks.is_owner() async def helpset(self, ctx: commands.Context): diff --git a/redbot/core/utils/menus.py b/redbot/core/utils/menus.py index a78e9633f..693681c4d 100644 --- a/redbot/core/utils/menus.py +++ b/redbot/core/utils/menus.py @@ -6,18 +6,43 @@ import asyncio import contextlib import functools from types import MappingProxyType -from typing import Callable, Iterable, List, Mapping, Optional, TypeVar, Union +from typing import Callable, Dict, Iterable, List, Mapping, Optional, TypeVar, Union import discord from .. import commands from .predicates import ReactionPredicate +from .views import SimpleMenu, _SimplePageSource _T = TypeVar("_T") _PageList = TypeVar("_PageList", List[str], List[discord.Embed]) _ReactableEmoji = Union[str, discord.Emoji] _ControlCallable = Callable[[commands.Context, _PageList, discord.Message, int, float, str], _T] +_active_menus: Dict[int, SimpleMenu] = {} + + +class _GenericButton(discord.ui.Button): + def __init__(self, emoji: Union[str, discord.PartialEmoji], func): + super().__init__( + emoji=discord.PartialEmoji.from_str(emoji), style=discord.ButtonStyle.grey + ) + self.func = func + + async def callback(self, interaction: discord.Interaction): + ctx = self.view.ctx + pages = self.view.source.entries + controls = None + message = self.view.message + page = self.view.current_page + timeout = self.view.timeout + emoji = self.emoji + try: + await self.func(ctx, pages, controls, message, page, timeout, emoji) + except Exception: + pass + await interaction.response.defer() + async def menu( ctx: commands.Context, @@ -64,6 +89,18 @@ async def menu( RuntimeError If either of the notes above are violated """ + if message is not None and message.id in _active_menus: + # prevents the expected callback from going any further + # our custom button will always pass the message the view is + # attached to, allowing one to send multiple menus on the same + # context. + view = _active_menus[message.id] + if pages != view.source.entries: + view._source = _SimplePageSource(pages) + new_page = await view.get_page(page) + view.current_page = page + await view.message.edit(**new_page) + return if not isinstance(pages[0], (discord.Embed, str)): raise RuntimeError("Pages must be of type discord.Embed or str") if not all(isinstance(x, discord.Embed) for x in pages) and not all( @@ -81,6 +118,52 @@ async def menu( maybe_coro = value.func if not asyncio.iscoroutinefunction(maybe_coro): raise RuntimeError("Function must be a coroutine") + + if await ctx.bot.use_buttons() and message is None: + # Only send the button version if `message` is None + # This is because help deals with this menu in weird ways + # where the original message is already sent prior to starting. + # This is not normally the way we recommend sending this because + # internally we already include the emojis we expect. + if controls == DEFAULT_CONTROLS: + view = SimpleMenu(pages) + await view.start(ctx) + await view.wait() + return + else: + view = SimpleMenu(pages) + view.remove_item(view.last_button) + view.remove_item(view.first_button) + has_next = False + has_prev = False + has_close = False + to_add = {} + for emoji, func in controls.items(): + if func == next_page: + has_next = True + if emoji != view.forward_button.emoji: + view.forward_button.emoji = discord.PartialEmoji.from_str(emoji) + elif func == prev_page: + has_prev = True + if emoji != view.backward_button.emoji: + view.backward_button.emoji = discord.PartialEmoji.from_str(emoji) + elif func == close_menu: + has_close = True + else: + to_add[emoji] = func + if not has_next: + view.remove_item(view.forward_button) + if not has_prev: + view.remove_item(view.backward_button) + if not has_close: + view.remove_item(view.stop_button) + for emoji, func in to_add.items(): + view.add_item(_GenericButton(emoji, func)) + await view.start(ctx) + _active_menus[view.message.id] = view + await view.wait() + del _active_menus[view.message.id] + return current_page = pages[page] if not message: