Add buttons to help (#5634)

Co-authored-by: Zephyrkul <23347632+Zephyrkul@users.noreply.github.com>
Co-authored-by: Kowlin <10947836+Kowlin@users.noreply.github.com>
Co-authored-by: Jakub Kuczys <me@jacken.men>
This commit is contained in:
TrustyJAID 2022-10-12 08:29:10 -06:00 committed by GitHub
parent 4158244117
commit aaeb1b5daa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 362 additions and 32 deletions

View File

@ -120,7 +120,7 @@ class Red(
help__page_char_limit=1000,
help__max_pages_in_guild=2,
help__delete_delay=0,
help__use_menus=False,
help__use_menus=0,
help__show_hidden=False,
help__show_aliases=True,
help__verify_checks=True,
@ -1045,6 +1045,16 @@ class Red(
await self._schema_1_to_2()
schema_version += 1
await self._config.schema_version.set(schema_version)
if schema_version == 2:
await self._schema_2_to_3()
schema_version += 1
await self._config.schema_version.set(schema_version)
async def _schema_2_to_3(self):
log.info("Migrating help menus to enum values")
old = await self._config.help__use_menus()
if old is not None:
await self._config.help__use_menus.set(int(old))
async def _schema_1_to_2(self):
"""

View File

@ -31,6 +31,7 @@ import abc
import asyncio
from collections import namedtuple
from dataclasses import dataclass, asdict as dc_asdict
from enum import Enum
from typing import Union, List, AsyncIterator, Iterable, cast
import discord
@ -39,6 +40,7 @@ from discord.ext import commands as dpy_commands
from . import commands
from .context import Context
from ..i18n import Translator
from ..utils.views import SimpleMenu
from ..utils import can_user_react_in, menus
from ..utils.mod import mass_purge
from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results
@ -65,6 +67,14 @@ EmbedField = namedtuple("EmbedField", "name value inline")
EMPTY_STRING = "\N{ZERO WIDTH SPACE}"
class HelpMenuSetting(Enum):
disabled = 0
reactions = 1
buttons = 2
select = 3
selectonly = 4
@dataclass(frozen=True)
class HelpSettings:
"""
@ -78,7 +88,7 @@ class HelpSettings:
page_char_limit: int = 1000
max_pages_in_guild: int = 2
use_menus: bool = False
use_menus: HelpMenuSetting = HelpMenuSetting(0)
show_hidden: bool = False
show_aliases: bool = True
verify_checks: bool = True
@ -103,7 +113,8 @@ class HelpSettings:
Get the HelpSettings for the current context
"""
settings = await context.bot._config.help.all()
return cls(**settings)
menus = settings.pop("use_menus", 0)
return cls(**settings, use_menus=HelpMenuSetting(menus))
@property
def pretty(self):
@ -129,6 +140,14 @@ class HelpSettings:
tagline_info = ""
data["tagline_info"] = tagline_info
menus_str = {
HelpMenuSetting.disabled: _("No"),
HelpMenuSetting.reactions: _("Yes, reactions"),
HelpMenuSetting.buttons: _("Yes, buttons"),
HelpMenuSetting.select: _("Yes, buttons with select menu"),
HelpMenuSetting.selectonly: _("Yes, select menu only"),
}
data["use_menus"] = menus_str[self.use_menus]
return _(
"Maximum characters per page: {page_char_limit}"
@ -813,7 +832,31 @@ class RedHelpFormatter(HelpFormatterABC):
"""
Sends pages based on settings.
"""
if not (can_user_react_in(ctx.me, ctx.channel) and help_settings.use_menus):
if help_settings.use_menus.value >= HelpMenuSetting.buttons.value:
use_select = help_settings.use_menus.value == 3
select_only = help_settings.use_menus.value == 4
await SimpleMenu(
pages,
timeout=help_settings.react_timeout,
use_select_menu=use_select,
use_select_only=select_only,
).start(ctx)
elif (
can_user_react_in(ctx.me, ctx.channel)
and help_settings.use_menus is HelpMenuSetting.reactions
):
# Specifically ensuring the menu's message is sent prior to returning
m = await (ctx.send(embed=pages[0]) if embed else ctx.send(pages[0]))
c = menus.DEFAULT_CONTROLS if len(pages) > 1 else {"\N{CROSS MARK}": menus.close_menu}
# Allow other things to happen during menu timeout/interaction.
asyncio.create_task(
menus.menu(ctx, pages, c, message=m, timeout=help_settings.react_timeout)
)
# menu needs reactions added manually since we fed it a message
menus.start_adding_reactions(m, c.keys())
else:
max_pages_in_guild = help_settings.max_pages_in_guild
use_DMs = len(pages) > max_pages_in_guild
destination = ctx.author if use_DMs else ctx.channel
@ -855,16 +898,6 @@ class RedHelpFormatter(HelpFormatterABC):
await mass_purge(messages, channel)
asyncio.create_task(_delete_delay_help(destination, messages, delete_delay))
else:
# Specifically ensuring the menu's message is sent prior to returning
m = await (ctx.send(embed=pages[0]) if embed else ctx.send(pages[0]))
c = menus.DEFAULT_CONTROLS if len(pages) > 1 else {"\N{CROSS MARK}": menus.close_menu}
# Allow other things to happen during menu timeout/interaction.
asyncio.create_task(
menus.menu(ctx, pages, c, message=m, timeout=help_settings.react_timeout)
)
# menu needs reactions added manually since we fed it a message
menus.start_adding_reactions(m, c.keys())
@commands.command(name="help", hidden=True, i18n=_)

View File

@ -22,7 +22,18 @@ from redbot.core.utils.menus import menu
from redbot.core.utils.views import SetApiView
from redbot.core.commands import GuildConverter, RawUserIdConverter
from string import ascii_letters, digits
from typing import TYPE_CHECKING, Union, Tuple, List, Optional, Iterable, Sequence, Dict, Set
from typing import (
TYPE_CHECKING,
Union,
Tuple,
List,
Optional,
Iterable,
Sequence,
Dict,
Set,
Literal,
)
import aiohttp
import discord
@ -54,6 +65,7 @@ from .utils.chat_formatting import (
)
from .commands import CommandConverter, CogConverter
from .commands.requires import PrivilegeLevel
from .commands.help import HelpMenuSetting
_entities = {
"*": "&midast;",
@ -3680,30 +3692,47 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
)
@helpset.command(name="usemenus")
async def helpset_usemenus(self, ctx: commands.Context, use_menus: bool = None):
async def helpset_usemenus(
self,
ctx: commands.Context,
use_menus: Literal["buttons", "reactions", "select", "selectonly", "disable"],
):
"""
Allows the help command to be sent as a paginated menu instead of separate
messages.
When enabled, `[p]help` will only show one page at a time and will use reactions to navigate between pages.
This defaults to False.
Using this without a setting will toggle.
When "reactions", "buttons", "select", or "selectonly" is passed,
`[p]help` will only show one page at a time
and will use the associated control scheme to navigate between pages.
**Examples:**
- `[p]helpset usemenus True` - Enables using menus.
- `[p]helpset usemenus` - Toggles the value.
- `[p]helpset usemenus reactions` - Enables using reaction menus.
- `[p]helpset usemenus buttons` - Enables using button menus.
- `[p]helpset usemenus select` - Enables buttons with a select menu.
- `[p]helpset usemenus selectonly` - Enables a select menu only on help.
- `[p]helpset usemenus disable` - Disables help menus.
**Arguments:**
- `[use_menus]` - Whether to use menus. Leave blank to toggle.
- `<"buttons"|"reactions"|"select"|"selectonly"|"disable">` - Whether to use `buttons`,
`reactions`, `select`, `selectonly`, or no menus.
"""
if use_menus is None:
use_menus = not await ctx.bot._config.help.use_menus()
await ctx.bot._config.help.use_menus.set(use_menus)
if use_menus:
await ctx.send(_("Help will use menus."))
else:
await ctx.send(_("Help will not use menus."))
if use_menus == "selectonly":
msg = _("Help will use the select menu only.")
await ctx.bot._config.help.use_menus.set(4)
if use_menus == "select":
msg = _("Help will use button menus and add a select menu.")
await ctx.bot._config.help.use_menus.set(3)
if use_menus == "buttons":
msg = _("Help will use button menus.")
await ctx.bot._config.help.use_menus.set(2)
if use_menus == "reactions":
msg = _("Help will use reaction menus.")
await ctx.bot._config.help.use_menus.set(1)
if use_menus == "disabled":
msg = _("Help will not use menus.")
await ctx.bot._config.help.use_menus.set(0)
await ctx.send(msg)
@helpset.command(name="showhidden")
async def helpset_showhidden(self, ctx: commands.Context, show_hidden: bool = None):

View File

@ -3,12 +3,270 @@ from __future__ import annotations
import discord
from discord.ext.commands import BadArgument
from typing import List, Dict, Union, Optional
from redbot.core.commands.converter import get_dict_converter
from typing import TYPE_CHECKING, Any, List, Optional, Union, Dict
from redbot.core.i18n import Translator
from redbot.vendored.discord.ext import menus
from redbot.core.commands.converter import get_dict_converter
if TYPE_CHECKING:
from redbot.core.commands import Context
_ = Translator("UtilsViews", __file__)
_ACCEPTABLE_PAGE_TYPES = Union[Dict[str, Union[str, discord.Embed]], discord.Embed, str]
class _SimplePageSource(menus.ListPageSource):
def __init__(self, items: List[_ACCEPTABLE_PAGE_TYPES]):
super().__init__(items, per_page=1)
async def format_page(
self, view: discord.ui.View, page: _ACCEPTABLE_PAGE_TYPES
) -> Union[str, discord.Embed]:
return page
class _SelectMenu(discord.ui.Select):
def __init__(self, options: List[discord.SelectOption]):
super().__init__(
placeholder=_("Select a Page"), min_values=1, max_values=1, options=options
)
async def callback(self, interaction: discord.Interaction):
index = int(self.values[0])
self.view.current_page = index
kwargs = await self.view.get_page(self.view.current_page)
await interaction.response.edit_message(**kwargs)
class _NavigateButton(discord.ui.Button):
def __init__(
self, style: discord.ButtonStyle, emoji: Union[str, discord.PartialEmoji], direction: int
):
super().__init__(style=style, emoji=emoji)
self.direction = direction
async def callback(self, interaction: discord.Interaction):
if self.direction == 0:
self.view.current_page = 0
elif self.direction == self.view.source.get_max_pages():
self.view.current_page = self.view.source.get_max_pages() - 1
else:
self.view.current_page += self.direction
kwargs = await self.view.get_page(self.view.current_page)
await interaction.response.edit_message(**kwargs)
class _StopButton(discord.ui.Button):
def __init__(self, style: discord.ButtonStyle, emoji: Union[str, discord.PartialEmoji]):
super().__init__(style=style, emoji=emoji)
async def callback(self, interaction: discord.Interaction):
self.view.stop()
if interaction.message.flags.ephemeral:
await interaction.response.edit_message(view=None)
return
await interaction.message.delete()
class SimpleMenu(discord.ui.View):
"""
A simple Button menu
Parameters
----------
pages: `list` of `str`, `discord.Embed`, or `dict`.
The pages of the menu.
if the page is a `dict` its keys must be valid messageable args.
e,g. "content", "embed", etc.
page_start: int
The page to start the menu at.
timeout: float
The time (in seconds) to wait for a reaction
defaults to 180 seconds.
delete_after_timeout: bool
Whether or not to delete the message after
the timeout has expired.
Defaults to False.
disable_after_timeout: bool
Whether to disable all components on the
menu after timeout has expired. By default
the view is removed from the message on timeout.
Defaults to False.
use_select_menu: bool
Whether or not to include a select menu
to jump specifically between pages.
Defaults to False.
use_select_only: bool
Whether the menu will only display the select
menu for paginating instead of the buttons.
The stop button will remain but is positioned
under the select menu in this instance.
Defaults to False.
Examples
--------
You can provide a list of strings::
from redbot.core.utils.views import SimpleMenu
pages = ["Hello", "Hi", "Bonjour", "Salut"]
await SimpleMenu(pages).start(ctx)
You can provide a list of dicts::
from redbot.core.utils.views import SimpleMenu
pages = [{"content": "My content", "embed": discord.Embed(description="hello")}]
await SimpleMenu(pages).start(ctx)
"""
def __init__(
self,
pages: List[_ACCEPTABLE_PAGE_TYPES],
timeout: float = 180.0,
page_start: int = 0,
delete_after_timeout: bool = False,
disable_after_timeout: bool = False,
use_select_menu: bool = False,
use_select_only: bool = False,
) -> None:
super().__init__(
timeout=timeout,
)
self.author: Optional[discord.abc.User] = None
self.message: Optional[discord.Message] = None
self._source = _SimplePageSource(items=pages)
self.ctx: Optional[Context] = None
self.current_page = page_start
self.delete_after_timeout = delete_after_timeout
self.disable_after_timeout = disable_after_timeout
self.use_select_menu = use_select_menu or use_select_only
self.use_select_only = use_select_only
self.forward_button = _NavigateButton(
discord.ButtonStyle.grey,
"\N{BLACK RIGHT-POINTING TRIANGLE}\N{VARIATION SELECTOR-16}",
direction=1,
)
self.backward_button = _NavigateButton(
discord.ButtonStyle.grey,
"\N{BLACK LEFT-POINTING TRIANGLE}\N{VARIATION SELECTOR-16}",
direction=-1,
)
self.first_button = _NavigateButton(
discord.ButtonStyle.grey,
"\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\N{VARIATION SELECTOR-16}",
direction=0,
)
self.last_button = _NavigateButton(
discord.ButtonStyle.grey,
"\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\N{VARIATION SELECTOR-16}",
direction=self.source.get_max_pages(),
)
self.select_options = [
discord.SelectOption(label=_("Page {num}").format(num=num + 1), value=num)
for num, x in enumerate(pages)
]
self.stop_button = _StopButton(
discord.ButtonStyle.red, "\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}"
)
self.select_menu = self._get_select_menu()
self.add_item(self.stop_button)
if self.source.is_paginating() and not self.use_select_only:
self.add_item(self.first_button)
self.add_item(self.backward_button)
self.add_item(self.forward_button)
self.add_item(self.last_button)
if self.use_select_menu and self.source.is_paginating():
if self.use_select_only:
self.remove_item(self.stop_button)
self.add_item(self.select_menu)
self.add_item(self.stop_button)
else:
self.add_item(self.select_menu)
@property
def source(self):
return self._source
async def on_timeout(self):
if self.delete_after_timeout and not self.message.flags.ephemeral:
await self.message.delete()
elif self.disable_after_timeout:
for child in self.children:
child.disabled = True
await self.message.edit(view=self)
else:
await self.message.edit(view=None)
def _get_select_menu(self):
# handles modifying the select menu if more than 25 pages are provided
# this will show the previous 12 and next 13 pages in the select menu
# based on the currently displayed page. Once you reach close to the max
# pages it will display the last 25 pages.
if len(self.select_options) > 25:
minus_diff = None
plus_diff = 25
if 12 < self.current_page < len(self.select_options) - 25:
minus_diff = self.current_page - 12
plus_diff = self.current_page + 13
elif self.current_page >= len(self.select_options) - 25:
minus_diff = len(self.select_options) - 25
plus_diff = None
options = self.select_options[minus_diff:plus_diff]
else:
options = self.select_options[:25]
return _SelectMenu(options)
async def start(self, ctx: Context, *, ephemeral: bool = False):
"""
Used to start the menu displaying the first page requested.
Parameters
----------
ctx: `commands.Context`
The context to start the menu in.
ephemeral: `bool`
Send the message ephemerally. This only works
if the context is from a slash command interaction.
"""
self.author = ctx.author
self.ctx = ctx
kwargs = await self.get_page(self.current_page)
self.message = await ctx.send(**kwargs, ephemeral=ephemeral)
async def get_page(self, page_num: int) -> Dict[str, Optional[Any]]:
try:
page = await self.source.get_page(page_num)
except IndexError:
self.current_page = 0
page = await self.source.get_page(self.current_page)
value = await self.source.format_page(self, page)
if self.use_select_menu and len(self.select_options) > 25 and self.source.is_paginating():
self.remove_item(self.select_menu)
self.select_menu = self._get_select_menu()
self.add_item(self.select_menu)
ret: Dict[str, Optional[Any]] = {"view": self}
if isinstance(value, dict):
ret.update(value)
elif isinstance(value, str):
ret.update({"content": value, "embed": None})
elif isinstance(value, discord.Embed):
ret.update({"embed": value, "content": None})
return ret
async def interaction_check(self, interaction: discord.Interaction):
"""Ensure only the author is allowed to interact with the menu."""
allowed_ids = (getattr(self.author, "id", None),)
if interaction.user.id not in allowed_ids:
await interaction.response.send_message(
content=_("You are not authorized to interact with this."), ephemeral=True
)
return False
return True
class SetApiModal(discord.ui.Modal):
"""