Skip to content

Menus and paginators

ballsdex.core.utils.menus.menus

Menu

Menu(bot: 'BallsDexBot', view: LayoutView, source: Source[P], *formatters: Formatter[P, Any])

A helper to have a pagination system inside of a LayoutView. It is possible to have multiple menus per view.

A menu needs an instance of Source for the pagination, and one or more Formatters which define how to display the current page. The source and formatters are not directly linked, but must follow the same type constraints, use your type checker to ensure you are using compatible classes.

If there are multiple pages, then this class will add a row of buttons to the position you choose via init.

Example

Simple pagination for a select list, divided in sections of 25

from ballsdex.core.utils.menus import *
from discord.ui import *

my_options = [...]

view = discord.ui.LayoutView()
select = discord.ui.Select()
view.add_item(select)

# max number of options for a select is 25
source = ChunkedListSource(my_options, per_page=25)
# the formatter is only linked to its UI element, not the source itself
formatter = SelectFormatter(select)

menu = Menu(self.bot, view, source, formatter)
# by default, this will add the control buttons at the end
await menu.init()
await interaction.response.send_message(view=view)

Another example with a list of TextDisplay items, dynamically sized to respect the view's limits

from ballsdex.core.utils.menus import *
from discord.ui import *

async def generate_options():
    async for item in queryset:
        yield TextDisplay("## Item title\nItem description...")

view = discord.ui.LayoutView()
container = discord.ui.Container()
container.add_item(Section(
    TextDisplay("# Message title"),
    TextDisplay("Message subtitle"),
    accessory=Thumbnail(user.display_avatar_url),
)
container.add_item(Separator())

source = ListSource(await dynamic_chunks(view, generate_options()))
formatter = ItemFormatter(container, position=2)  # insert after separator
menu = Menu(self.bot, view, source, formatter)
await menu.init()
await interaction.response.send_message(view=view)
Tip

Using a type checker can reveal incompatible types

from ballsdex.core.utils.menus import *

source = ModelSource(BallInstance.objects.filter(player=player))
formatter = TextSource(item)
menu = Menu(self.bot, view, source, formatter)
# Argument of type "TextSource" cannot be assigned to parameter "formatters" of type "Formatter[P@Menu, Any]" in function "__init__"
#   "TextSource" is not assignable to "Formatter[QuerySet[BallInstance], Any]"

Parameters:

  • bot ('BallsDexBot') –

    The bot instance. Unused by itself, but some formatters may find it useful to have it available.

  • view (LayoutView) –

    The view you are attaching to. This is incompatible with V1 views.

  • source (Source[P]) –

    The source instance providing the elements to paginate

  • *formatters (Formatter[P, Any], default: () ) –

    One or more formatters which will display the data from the source. They are attached to an item that belongs to the view.

Source code in ballsdex/core/utils/menus/menus.py
def __init__(self, bot: "BallsDexBot", view: LayoutView, source: Source[P], *formatters: Formatter[P, Any]):
    self.bot = bot
    self.view = view
    self.formatters = formatters
    for formatter in formatters:
        formatter.configure(self)
    self.source = source
    self.current_page = 0
    self.controls = Controls(self)

init

init(position: int | None = None, container: Container | None = None)

Prepare the menu before sending.

Parameters:

  • position (int | None, default: None ) –

    The position at which to insert the control buttons. If None, this will be at the end.

  • container (Container | None, default: None ) –

    If provided, the control buttons will be inserted inside the container instead of the outer view. The position parameter is respected within the container.

Source code in ballsdex/core/utils/menus/menus.py
async def init(self, position: int | None = None, container: discord.ui.Container | None = None):
    """
    Prepare the menu before sending.

    Parameters
    ----------
    position: int | None
        The position at which to insert the control buttons. If `None`, this will be at the end.
    container: discord.ui.Container | None
        If provided, the control buttons will be inserted inside the container instead of the outer view. The
        `position` parameter is respected within the container.
    """
    await self.source.prepare()
    await self.set_page(0)
    if self.source.get_max_pages() <= 1:
        return
    item = container or self.view
    if not position:
        item.add_item(self.controls)
        return

    # View only supports appending at the end, not inserting, so it's done manually
    self.view._add_count(self.controls._total_count)
    if position:
        item._children.insert(position, self.controls)
    else:
        item._children.append(self.controls)
    if container:
        container._update_view(self.view)
        self.controls._parent = container

ballsdex.core.utils.menus.source

Source

A source of long items to paginate and display over a LayoutView.

get_max_pages

get_max_pages() -> int

Returns the maximum number of pages in the iterable.

Source code in ballsdex/core/utils/menus/source.py
def get_max_pages(self) -> int:
    """
    Returns the maximum number of pages in the iterable.
    """
    raise NotImplementedError

get_page

get_page(page_number: int) -> P

Returns one page of type P.

Parameters:

  • page_number (int) –

    The page number requested. Cannot be negative, and strictly lower than the result of get_max_pages.

Source code in ballsdex/core/utils/menus/source.py
async def get_page(self, page_number: int) -> P:
    """
    Returns one page of type `P`.

    Parameters
    ----------
    page_number: int
        The page number requested. Cannot be negative, and strictly lower than the result of
        `get_max_pages`.
    """
    raise NotImplementedError

prepare

prepare()

Called before starting the pagination.

Source code in ballsdex/core/utils/menus/source.py
async def prepare(self):
    """
    Called before starting the pagination.
    """
    pass

ListSource

ListSource(items: list[P])

Bases: Source[P]

Source code in ballsdex/core/utils/menus/source.py
def __init__(self, items: list[P]):
    super().__init__()
    self.items = items

prepare

prepare()

Called before starting the pagination.

Source code in ballsdex/core/utils/menus/source.py
async def prepare(self):
    """
    Called before starting the pagination.
    """
    pass

ChunkedListSource

ChunkedListSource(items: list[P], per_page: int = 25)

Bases: Source[list[P]]

Source code in ballsdex/core/utils/menus/source.py
def __init__(self, items: list[P], per_page: int = 25):
    super().__init__()
    self.items = items
    self.per_page = per_page

prepare

prepare()

Called before starting the pagination.

Source code in ballsdex/core/utils/menus/source.py
async def prepare(self):
    """
    Called before starting the pagination.
    """
    pass

TextSource

TextSource(text: str, delims: Sequence[str] = ['\n#', '\n##', '\n###', '\n\n', '\n'], *, priority: bool = True, escape_mass_mentions: bool = True, shorten_by: int = 8, page_length: int = 3900, prefix: str = '', suffix: str = '')

Bases: ListSource[str]

Source code in ballsdex/core/utils/menus/source.py
def __init__(
    self,
    text: str,
    delims: Sequence[str] = ["\n#", "\n##", "\n###", "\n\n", "\n"],
    *,
    priority: bool = True,
    escape_mass_mentions: bool = True,
    shorten_by: int = 8,
    page_length: int = 3900,
    prefix: str = "",
    suffix: str = "",
):
    pages = pagify(
        text,
        delims,
        priority=priority,
        escape_mass_mentions=escape_mass_mentions,
        shorten_by=shorten_by,
        page_length=page_length,
        prefix=prefix,
        suffix=suffix,
    )
    super().__init__(list(pages))

get_max_pages

get_max_pages() -> int

Returns the maximum number of pages in the iterable.

Source code in ballsdex/core/utils/menus/source.py
def get_max_pages(self) -> int:
    """
    Returns the maximum number of pages in the iterable.
    """
    raise NotImplementedError

get_page

get_page(page_number: int) -> P

Returns one page of type P.

Parameters:

  • page_number (int) –

    The page number requested. Cannot be negative, and strictly lower than the result of get_max_pages.

Source code in ballsdex/core/utils/menus/source.py
async def get_page(self, page_number: int) -> P:
    """
    Returns one page of type `P`.

    Parameters
    ----------
    page_number: int
        The page number requested. Cannot be negative, and strictly lower than the result of
        `get_max_pages`.
    """
    raise NotImplementedError

prepare

prepare()

Called before starting the pagination.

Source code in ballsdex/core/utils/menus/source.py
async def prepare(self):
    """
    Called before starting the pagination.
    """
    pass

ModelSource

ModelSource(queryset: QuerySet[M], per_page: int = 25)

Bases: Source['QuerySet[M]']

Source code in ballsdex/core/utils/menus/source.py
def __init__(self, queryset: "QuerySet[M]", per_page: int = 25) -> None:
    super().__init__()
    self.per_page = per_page
    self.queryset = queryset

ballsdex.core.utils.menus.formatter

Formatter

Formatter(item: I)

A class that edits one of the layout's components from a page of the menu.

Parameters:

  • item (I) –

    An item to edit from a page. Must be part of the view attached to the menu.

Source code in ballsdex/core/utils/menus/formatter.py
def __init__(self, item: I):
    self.menu: "Menu[P]"
    self.item = item

format_page

format_page(page: P) -> None

Edits the item attached with the given page.

Parameters:

  • page (P) –

    The current page of the menu

Source code in ballsdex/core/utils/menus/formatter.py
async def format_page(self, page: P) -> None:
    """
    Edits the `item` attached with the given page.

    Parameters
    ----------
    page: P
        The current page of the menu
    """
    raise NotImplementedError

ItemFormatter

ItemFormatter(item: Container, position: int, footer: bool = True)

Bases: Formatter[Iterable[Item], Container]

This formatter takes as source a list of UI items, and dynamically add them to the given container. Useful for iterations where a list of Section or TextDisplay need to be given.

You are responsible of passing a list of items that respect the container limits. Use dynamic_chunks to pagify your items while respecting limits.

Parameters:

  • item (Container) –

    Must be of container type.

  • position (int) –

    The position at which items must be inserted.

  • footer (bool, default: True ) –

    Whether to include a "Page 1/max" footer at the end.

Source code in ballsdex/core/utils/menus/formatter.py
def __init__(self, item: discord.ui.Container, position: int, footer: bool = True):
    super().__init__(item)
    self.position = position
    self.footer = footer

ballsdex.core.utils.menus.utils

dynamic_chunks

dynamic_chunks[I: Item](view: LayoutView, source: AsyncIterable[I]) -> list[list[I]]

Transform an iterable of Items into a list of lists. Each sublist is guaranteed to fit the limits of the view given as argument.

This is useful combined with ItemFormatter to display a dynamically-sized list of items.

Warning

This will ensure the limits of the view are respected at the time this function is called. Do not append new items to your view after calling this function as the results will be inaccurate.

Parameters:

  • view (LayoutView) –

    The finished view that will have the items appended to. The position does not matter as it only checks for global limits.

  • source (AsyncIterable[I]) –

    The generator providing the items. This is asynchronous in case you are doing this along an asynchronous iterator, like a database query or a Discord paginator.

Returns:

  • list[list[I]]

    The chunked list of items.

Source code in ballsdex/core/utils/menus/utils.py
async def dynamic_chunks[I: discord.ui.Item](view: discord.ui.LayoutView, source: AsyncIterable[I]) -> list[list[I]]:
    """
    Transform an iterable of [`Item`][discord.ui.Item]s into a list of lists. Each sublist is guaranteed to fit the
    limits of the view given as argument.

    This is useful combined with [`ItemFormatter`][ballsdex.core.utils.menus.ItemFormatter] to display a
    dynamically-sized list of items.

    Warning
    -------
    This will ensure the limits of the view are respected at the time this function is called. Do not append new
    items to your view after calling this function as the results will be inaccurate.

    Parameters
    ----------
    view: discord.ui.LayoutView
        The finished view that will have the items appended to. The position does not matter as it only checks for
        global limits.
    source: AsyncIterable[I]
        The generator providing the items. This is asynchronous in case you are doing this along an asynchronous
        iterator, like a database query or a Discord paginator.

    Returns
    -------
    list[list[I]]
        The chunked list of items.
    """
    sections = []
    current_chunk = []
    async for item in source:
        view.add_item(item)
        current_chunk.append(item)
        if view.content_length() > 5900 or view.total_children_count > 30:
            sections.append(current_chunk)
            for old_item in current_chunk:
                view.remove_item(old_item)
            current_chunk = []
    if current_chunk:
        sections.append(current_chunk)
        for old_item in current_chunk:
            view.remove_item(old_item)
    return sections

iter_to_async

iter_to_async[T](source: Iterable[T]) -> AsyncIterable[T]

A helper to transform a synchronous iterable into an asynchronous one, useful for dynamic_chunks.

Source code in ballsdex/core/utils/menus/utils.py
async def iter_to_async[T](source: Iterable[T]) -> AsyncIterable[T]:
    """
    A helper to transform a synchronous iterable into an asynchronous one, useful for `dynamic_chunks`.
    """
    for x in source:
        yield x