66import logging
77import re
88from 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
1415log = logging .getLogger (__name__ )
1516
17+ DEFAULT_TIMEOUT = 180.0
1618
1719class 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
765822class 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+
10141164class ListPageSource (PageSource ):
10151165 """A data source for a sequence of items.
10161166
0 commit comments