Skip to content

Commit b182dcb

Browse files
authored
Merge pull request #1 from DenverCoderOne/feature/menu-with-buttons
Added button component menus
2 parents 76a2735 + 3469a51 commit b182dcb

File tree

2 files changed

+223
-19
lines changed

2 files changed

+223
-19
lines changed

README.md

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
# nextcord-ext-menus
22

3-
## About
3+
A Nextcord extension that makes working with reaction menus and button component menus a bit easier.
44

5-
A Nextcord extension that makes working with reaction menus a bit easier.
6-
7-
## Installing
5+
# Installing
86

97
Python **>=3.6.0** is required.
108

119
```py
1210
pip install --upgrade nextcord-ext-menus
1311
```
1412

15-
## Getting Started
13+
# Getting Started
14+
15+
## Reaction Menus
1616

1717
To whet your appetite, the following examples show the fundamentals on how to create menus.
1818

@@ -43,8 +43,7 @@ Now, within a command we just instantiate it and we start it like so:
4343
```py
4444
@bot.command()
4545
async def menu_example(ctx):
46-
m = MyMenu()
47-
await m.start(ctx)
46+
await MyMenu().start(ctx)
4847
```
4948

5049
If an error happens then an exception of type `menus.MenuError` is raised.
@@ -175,6 +174,61 @@ pages = menus.MenuPages(source=Source(), clear_reactions_after=True)
175174
await pages.start(ctx)
176175
```
177176

177+
## Button Component Menus
178+
179+
Here is a button implementation of a basic menu that has a stop button and two reply reactions.
180+
181+
Note that the `ButtonMenu` class is used instead of `Menu` in order to make it a `View`. `ButtonMenu` is a subclass of `Menu` and it therefore has all the same attributes and methods.
182+
183+
Also note that `view=self` is passed with the initial message and `nextcord.ui.button` is used instead of `menus.button`.
184+
185+
`ButtonMenu.disable` can be used to disable all buttons in the menu.
186+
187+
`ButtonMenu.enable` can be used to enable all buttons in the menu.
188+
189+
```py
190+
import nextcord
191+
from nextcord.ext import menus
192+
193+
class MyButtonMenu(menus.ButtonMenu):
194+
async def send_initial_message(self, ctx, channel):
195+
return await channel.send(f'Hello {ctx.author}', view=self)
196+
197+
@nextcord.ui.button(emoji="\N{THUMBS UP SIGN}")
198+
async def on_thumbs_up(self, button, interaction):
199+
await self.message.edit(content=f"Thanks {interaction.user}!")
200+
201+
@nextcord.ui.button(emoji="\N{THUMBS DOWN SIGN}")
202+
async def on_thumbs_down(self, button, interaction):
203+
await self.message.edit(content=f"That's not nice {interaction.user}...")
204+
205+
@nextcord.ui.button(emoji="\N{BLACK SQUARE FOR STOP}\ufe0f")
206+
async def on_stop(self, button, interaction):
207+
await self.disable()
208+
self.stop()
209+
```
210+
211+
Instantiation is the same as above.
212+
213+
```py
214+
await MyButtonMenu().start(ctx)
215+
```
216+
217+
### Pagination
218+
219+
A `ButtonMenuPages` class is provided for pagination with button components.
220+
221+
`ButtonMenuPages` works the same way as the `MenuPages` class found above, but with button components instead of reactions.
222+
223+
A `ButtonStyle` can optionally be passed in to customize the appearance.
224+
225+
`MySource` is the same as defined above, but the menu is instantiated with:
226+
227+
```py
228+
pages = menus.ButtonMenuPages(source=MySource(range(1, 100)), style=nextcord.ButtonStyle.primary)
229+
await pages.start(ctx)
230+
```
231+
178232
## License
179233

180234
Copyright (c) 2021 The Nextcord Developers

nextcord/ext/menus/__init__.py

Lines changed: 162 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
import logging
77
import re
88
from collections import OrderedDict, namedtuple
9+
from typing import List, Union
910

1011
# Needed for the setup.py script
1112
__version__ = '1.0.0'
1213

1314
# consistency with the `nextcord` namespaced logging
1415
log = logging.getLogger(__name__)
1516

17+
DEFAULT_TIMEOUT = 180.0
1618

1719
class MenuError(Exception):
1820
pass
@@ -311,7 +313,7 @@ class Menu(metaclass=_MenuMeta):
311313
calling :meth:`send_initial_message`\, if for example you have a pre-existing
312314
message you want to attach a menu to.
313315
"""
314-
def __init__(self, *, timeout=180.0, delete_message_after=False,
316+
def __init__(self, *, timeout=DEFAULT_TIMEOUT, delete_message_after=False,
315317
clear_reactions_after=False, check_embeds=False, message=None):
316318

317319
self.timeout = timeout
@@ -504,7 +506,15 @@ async def dummy():
504506

505507
def should_add_reactions(self):
506508
""":class:`bool`: Whether to add reactions to this menu session."""
507-
return len(self.buttons)
509+
return len(self.buttons) > 0
510+
511+
def should_add_buttons(self):
512+
""":class:`bool`: Whether to add button components to this menu session."""
513+
return hasattr(self, 'children') and len(self.children) > 0
514+
515+
def should_add_reactions_or_buttons(self):
516+
""":class:`bool`: Whether to add reactions or buttons to this menu session."""
517+
return self.should_add_reactions() or self.should_add_buttons()
508518

509519
def _verify_permissions(self, ctx, channel, permissions):
510520
if not permissions.send_messages:
@@ -698,7 +708,7 @@ async def start(self, ctx, *, channel=None, wait=False):
698708
if msg is None:
699709
self.message = msg = await self.send_initial_message(ctx, channel)
700710

701-
if self.should_add_reactions():
711+
if self.should_add_reactions_or_buttons():
702712
# Start the task first so we can listen to reactions before doing anything
703713
for task in self.__tasks:
704714
task.cancel()
@@ -761,6 +771,53 @@ def stop(self):
761771
task.cancel()
762772
self.__tasks.clear()
763773

774+
class ButtonMenu(Menu, nextcord.ui.View):
775+
r"""An interface that allows handling menus by using button interaction components.
776+
777+
Buttons should be marked with the :func:`nextcord.ui.button` decorator. Please note that
778+
this expects the methods to have two parameters, the ``button`` and the ``interaction``.
779+
The ``button`` is of type :class:`nextcord.ui.Button`.
780+
The ``interaction`` is of type :class:`nextcord.Interaction`.
781+
"""
782+
def __init__(self, timeout=DEFAULT_TIMEOUT, *args, **kwargs):
783+
Menu.__init__(self, timeout=timeout, *args, **kwargs)
784+
nextcord.ui.View.__init__(self, timeout=timeout)
785+
786+
async def _set_all_disabled(self, disable: bool):
787+
"""|coro|
788+
789+
Enables or disable all :class:`nextcord.ui.Button` components in the menu.
790+
791+
Parameters
792+
------------
793+
disable: :class:`bool`
794+
Whether to disable or enable the buttons.
795+
"""
796+
for child in self.children:
797+
child.disabled = disable
798+
await self.message.edit(view=self)
799+
800+
async def enable(self):
801+
"""|coro|
802+
803+
Enables all :class:`nextcord.ui.Button` components in the menu.
804+
"""
805+
await self._set_all_disabled(False)
806+
807+
async def disable(self):
808+
"""|coro|
809+
810+
Disables all :class:`nextcord.ui.Button` components in the menu.
811+
"""
812+
await self._set_all_disabled(True)
813+
814+
def stop(self):
815+
"""Stops the internal loop and view interactions."""
816+
# stop the menu loop
817+
Menu.stop(self)
818+
# stop view interactions
819+
nextcord.ui.View.stop(self)
820+
764821

765822
class PageSource:
766823
"""An interface representing a menu page's data source for the actual menu page.
@@ -797,7 +854,7 @@ async def prepare(self):
797854
return
798855

799856
def is_paginating(self):
800-
"""An abstract method that notifies the :class:`MenuPages` whether or not
857+
"""An abstract method that notifies the :class:`MenuPagesBase` whether or not
801858
to start paginating. This signals whether to add reactions or not.
802859
803860
Subclasses must implement this.
@@ -883,15 +940,21 @@ async def format_page(self, menu, page):
883940
raise NotImplementedError
884941

885942

886-
class MenuPages(Menu):
887-
"""A special type of Menu dedicated to pagination.
943+
class MenuPagesBase(ButtonMenu):
944+
"""A base class dedicated to pagination for reaction and button menus.
888945
889946
Attributes
890947
------------
891948
current_page: :class:`int`
892949
The current page that we are in. Zero-indexed
893950
between [0, :attr:`PageSource.max_pages`).
894951
"""
952+
FIRST_PAGE = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f'
953+
PREVIOUS_PAGE = '\N{BLACK LEFT-POINTING TRIANGLE}\ufe0f'
954+
NEXT_PAGE = '\N{BLACK RIGHT-POINTING TRIANGLE}\ufe0f'
955+
LAST_PAGE = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f'
956+
STOP = '\N{BLACK SQUARE FOR STOP}\ufe0f'
957+
895958
def __init__(self, source, **kwargs):
896959
self._source = source
897960
self.current_page = 0
@@ -954,6 +1017,8 @@ async def send_initial_message(self, ctx, channel):
9541017
"""
9551018
page = await self._source.get_page(0)
9561019
kwargs = await self._get_kwargs_from_page(page)
1020+
if hasattr(self, '__discord_ui_view__'):
1021+
kwargs['view'] = self
9571022
return await channel.send(**kwargs)
9581023

9591024
async def start(self, ctx, *, channel=None, wait=False):
@@ -982,24 +1047,35 @@ def _skip_double_triangle_buttons(self):
9821047
return True
9831048
return max_pages <= 2
9841049

985-
@button('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f',
986-
position=First(0), skip_if=_skip_double_triangle_buttons)
1050+
1051+
class MenuPages(MenuPagesBase):
1052+
"""A special type of Menu dedicated to pagination with reactions.
1053+
1054+
Attributes
1055+
------------
1056+
current_page: :class:`int`
1057+
The current page that we are in. Zero-indexed
1058+
between [0, :attr:`PageSource.max_pages`).
1059+
"""
1060+
def __init__(self, source, **kwargs):
1061+
super().__init__(source, **kwargs)
1062+
1063+
@button(MenuPagesBase.FIRST_PAGE, position=First(0), skip_if=MenuPagesBase._skip_double_triangle_buttons)
9871064
async def go_to_first_page(self, payload):
9881065
"""go to the first page"""
9891066
await self.show_page(0)
9901067

991-
@button('\N{BLACK LEFT-POINTING TRIANGLE}\ufe0f', position=First(1))
1068+
@button(MenuPagesBase.PREVIOUS_PAGE, position=First(1))
9921069
async def go_to_previous_page(self, payload):
9931070
"""go to the previous page"""
9941071
await self.show_checked_page(self.current_page - 1)
9951072

996-
@button('\N{BLACK RIGHT-POINTING TRIANGLE}\ufe0f', position=Last(0))
1073+
@button(MenuPagesBase.NEXT_PAGE, position=Last(0))
9971074
async def go_to_next_page(self, payload):
9981075
"""go to the next page"""
9991076
await self.show_checked_page(self.current_page + 1)
10001077

1001-
@button('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f',
1002-
position=Last(1), skip_if=_skip_double_triangle_buttons)
1078+
@button(MenuPagesBase.LAST_PAGE, position=Last(1), skip_if=MenuPagesBase._skip_double_triangle_buttons)
10031079
async def go_to_last_page(self, payload):
10041080
"""go to the last page"""
10051081
# The call here is safe because it's guarded by skip_if
@@ -1011,6 +1087,80 @@ async def stop_pages(self, payload):
10111087
self.stop()
10121088

10131089

1090+
class MenuPaginationButton(nextcord.ui.Button['MenuPaginationButton']):
1091+
"""
1092+
A custom button for pagination that will be disabled when unavailable.
1093+
"""
1094+
def __init__(self, style: nextcord.ButtonStyle, emoji: Union[str, nextcord.Emoji, nextcord.PartialEmoji]):
1095+
super().__init__(style=style, emoji=emoji)
1096+
self._emoji = _cast_emoji(emoji)
1097+
1098+
async def callback(self, interaction: nextcord.Interaction):
1099+
"""
1100+
Callback for when this button is pressed
1101+
"""
1102+
assert self.view is not None
1103+
view: ButtonMenuPages = self.view
1104+
1105+
# change the current page
1106+
if self._emoji.name == view.FIRST_PAGE:
1107+
await view.show_page(0)
1108+
elif self._emoji.name == view.PREVIOUS_PAGE:
1109+
await view.show_checked_page(view.current_page - 1)
1110+
elif self._emoji.name == view.NEXT_PAGE:
1111+
await view.show_checked_page(view.current_page + 1)
1112+
elif self._emoji.name == view.LAST_PAGE:
1113+
await view.show_page(view._source.get_max_pages() - 1)
1114+
1115+
# disable buttons that are unavailable
1116+
view._disable_unavailable_buttons()
1117+
1118+
# disable all buttons if stop is pressed
1119+
if self._emoji.name == view.STOP:
1120+
await view.disable()
1121+
view.stop()
1122+
1123+
# update the view
1124+
await interaction.response.edit_message(view=view)
1125+
1126+
1127+
class ButtonMenuPages(MenuPagesBase):
1128+
"""A special type of Menu dedicated to pagination with button components.
1129+
1130+
Parameters
1131+
-----------
1132+
style: :class:`nextcord.ui.ButtonStyle`
1133+
The button style to use for the pagination buttons.
1134+
1135+
Attributes
1136+
------------
1137+
current_page: :class:`int`
1138+
The current page that we are in. Zero-indexed
1139+
between [0, :attr:`PageSource.max_pages`).
1140+
"""
1141+
def __init__(self, source: PageSource, style: nextcord.ButtonStyle = nextcord.ButtonStyle.secondary, **kwargs):
1142+
super().__init__(source, **kwargs)
1143+
# add buttons to the view
1144+
for emoji in (self.FIRST_PAGE, self.PREVIOUS_PAGE, self.NEXT_PAGE, self.LAST_PAGE, self.STOP):
1145+
if emoji in (self.FIRST_PAGE, self.LAST_PAGE) and self._skip_double_triangle_buttons():
1146+
continue
1147+
self.add_item(MenuPaginationButton(style=style, emoji=emoji))
1148+
self._disable_unavailable_buttons()
1149+
1150+
def _disable_unavailable_buttons(self):
1151+
"""
1152+
Disables buttons that are unavailable to be pressed.
1153+
"""
1154+
buttons: List[MenuPaginationButton] = self.children
1155+
max_pages = self._source.get_max_pages()
1156+
for button in buttons:
1157+
if button.emoji.name in (self.FIRST_PAGE, self.PREVIOUS_PAGE):
1158+
button.disabled = self.current_page == 0
1159+
elif max_pages and button.emoji.name in (self.LAST_PAGE, self.NEXT_PAGE):
1160+
button.disabled = self.current_page == max_pages - 1
1161+
1162+
1163+
10141164
class ListPageSource(PageSource):
10151165
"""A data source for a sequence of items.
10161166

0 commit comments

Comments
 (0)