Source code for menus.base

# ../menus/base.py

"""Contains base menu functionality."""

# =============================================================================
# >> IMPORTS
# =============================================================================
# Python Imports
#   Collections
from collections import defaultdict
#   Math
import math
#   Weakref
from weakref import WeakValueDictionary

# Source.Python Imports
#   Core
from core import WeakAutoUnload
#   Filters
from filters.recipients import RecipientFilter
#   Listeners
from listeners import OnClientDisconnect
#   Players
from players.helpers import get_client_language
#   Translations
from translations.strings import TranslationStrings


# =============================================================================
# >> CLASSES
# =============================================================================
class _PlayerPage(object):
    """Stores information about the player's current active page."""

    def __init__(self):
        """Initialize the object."""
        self.index = 0
        self.options = {}


class _BaseMenu(WeakAutoUnload, list):
    """The base menu. Every menu class should inherit from this class."""

    _instances = WeakValueDictionary()

    def __init__(self, data=None, select_callback=None, build_callback=None, close_callback=None):
        """Initialize the menu.

        :param iterable|None data: Data that should be added to the menu.
        :param callable|None select_callback: A function that gets called
            whenever a selection was made.

            The callback will receive 3 parameters:
                1. The instance of this menu.
                2. The player's index who made the selection.
                3. The player's choice.

        :param callable|None build_callback: A function that gets called
            before a menu is displayed.

            The callback will receive 2 parameters:
                1. The instance of this menu.
                2. The index of the player who will receive this menu.

        :param callable|None close_callback: A function that gets called
            when a menu is closed by a player.

            The callback will receive 2 parameters:
                1. The instance of this menu.
                2. The index of the player who will close this menu.
        """
        super().__init__(list() if data is None else data)

        self.select_callback = select_callback
        self.build_callback = build_callback
        self.close_callback = close_callback
        self._player_pages = defaultdict(_PlayerPage)
        self._instances[id(self)] = self

    def _unload_instance(self):
        """Close this menu object for every player."""
        # Just close all open menus, which will remove all instances from the
        # queues.
        self.close()

        # Also remove the instance from the _instances dict.
        # This process is necessary because there is no guarantee that
        # the instance will be destroyed when the plugin is unloaded.
        del self._instances[id(self)]

    def _unload_player(self, player_index):
        """Remove every player specific information.

        :param int player_index: A player index.
        """
        self._player_pages.pop(player_index, 0)

    def _refresh(self, player_index):
        """Re-send the menu to a player.

        :param int player_index: The index of the player whose menu should be
            refreshed.
        """
        self._send(player_index)

    def _build(self, player_index):
        """Call the build callback and return all relevant menu data.

        :param int player_index: The index of the player whose menu should be
            built.
        """
        # Call the build callback if there is one
        if self.build_callback is not None:
            self.build_callback(self, player_index)

        return self._get_menu_data(player_index)

    def _select(self, player_index, choice_index):
        """Handle a menu selection.

        :param int player_index: The index of the player who made the
            selection.
        :param int choice_index: Defines what was selected.
        """
        if self.select_callback is not None:
            return self.select_callback(self, player_index, choice_index)

    def _select_close(self, player_index):
        """Handle the close menu selection.

        :param int player_index: The index of the player who made the
            selection.
        """
        if self.close_callback is not None:
            return self.close_callback(self, player_index)

    def send(self, *ply_indexes, **tokens):
        """Send the menu to the given player indexes.

        If no indexes were given, the menu will be sent to every player.

        :param ply_indexes:
            Player indexes that should receive the menu.
        :param tokens:
            Translation tokens for menu options, title and description.
        """
        ply_indexes = tuple(RecipientFilter(*ply_indexes))
        if not ply_indexes:
            return

        # Update option tokens
        for option in self:
            try:
                option.text.tokens.update(**tokens)
            except AttributeError:
                # Not a _MenuData or TranslationStrings instance
                pass

        # Update title if existant
        try:
            self.title.tokens.update(**tokens)
        except AttributeError:
            pass

        # Update description if existant
        try:
            self.description.tokens.update(**tokens)
        except AttributeError:
            pass

        for player_index in ply_indexes:
            queue = self.get_user_queue(player_index)
            queue.append(self)
            queue._refresh()

    def close(self, *ply_indexes):
        """Close the menu for the given player indexes.

        If no indexes were given, the menu will be closed for all players.
        """
        ply_indexes = RecipientFilter(*ply_indexes)
        for player_index in ply_indexes:
            queue = self.get_user_queue(player_index)

            # Try to remove this menu from the queue
            try:
                queue.remove(self)
            except ValueError:
                # If it fails, do nothing
                pass
            else:
                # If the queue is now empty, send an empty menu to hide
                # the last menu
                if not queue:
                    # Send an empty menu
                    self._close(player_index)

                else:
                    # There is at least one menu in the queue, so refresh to
                    # display it.
                    queue._refresh()

    def is_active_menu(self, player_index):
        """Return True if this menu is the first menu in the user's queue.

        :param int player_index: A player index.
        """
        return self.get_user_queue(player_index).active_menu is self

    @classmethod
    def get_user_queue(cls, player_index):
        """Return the menu queue for the given player.

        :param int player_index: A player index.
        """
        return cls._get_queue_holder()[player_index]

    @staticmethod
    def _get_queue_holder():
        """Return a _QueueHolder object.

        This method needs to be implemented by a subclass!
        """
        raise NotImplementedError

    def _get_menu_data(self, player_index):
        """Return the required data to send a menu.

        This method needs to be implemented by a subclass!

        :param int player_index: A player index.
        """
        raise NotImplementedError

    def _send(self, player_index):
        """Send a menu to the player.

        This method needs to be implemented by a subclass!

        :param int player_index: A player index.
        """
        raise NotImplementedError

    def _close(self, player_index):
        """Close a menu for the player by sending an empty menu.

        This method needs to be implemented by a subclass!

        :param int player_index: A player index.
        """
        raise NotImplementedError

    def register_select_callback(self, callback):
        """Register a select callback for the menu.

        Can and should be used as a decorator.

        :param callable callback: A function that gets called
            whenever a selection was made.

            The callback will receive 3 parameters:
                1. The instance of this menu.
                2. The player's index who made the selection.
                3. The player's choice.
        """
        self.select_callback = callback
        return callback

    def register_build_callback(self, callback):
        """Register a build callback for the menu.

        Can and should be used as a decorator.

        :param callable callback: A function that gets called
            before a menu is displayed.

            The callback will receive 2 parameters:
                1. The instance of this menu.
                2. The index of the player who will receive this menu.
        """
        self.build_callback = callback
        return callback

    def register_close_callback(self, callback):
        """Register a close callback for the menu.

        Can and should be used as a decorator.

        :param callable callback: A function that gets called
            when a menu is closed by a player.

            The callback will receive 2 parameters:
                1. The instance of this menu.
                2. The index of the player who will receive this menu.
        """
        self.close_callback = callback
        return callback


class _MenuData(object):
    """Base class for menu data.

    All data types should inherit from this class.
    """

    def __init__(self, text):
        """Initialize the instance.

        :param str text: The text that should be displayed.
        """
        self.text = text

    def _render(self, player_index, choice_index=None):
        """Render the data.

        :param int player_index: A player index.
        :param int|None choice_index: The number that was selected.
            It depends on the menu type if this parameter gets passed.
        """
        raise NotImplementedError


class _PagedMenuBase(object):
    """Implements the base of every page based menu."""

    @staticmethod
    def _get_max_item_count():
        """Return the maximum possible item count per page."""
        raise NotImplementedError

    def _get_options(self, page_index):
        """Return a tuple containing the options for the given page index.

        :param int page_index: The index of the page.
        """
        item_count = self._get_max_item_count()
        return self[page_index * item_count: (page_index + 1) * item_count]

    @property
    def last_page_index(self):
        """Return the index of the last page."""
        return self.page_count - 1

    @property
    def page_count(self):
        """Return the number of pages the menu currently has."""
        return int(math.ceil(len(self) / self._get_max_item_count())) or 1

    def set_player_page(self, player_index, page_index):
        """Set the player's current page index.

        :param int player_index: A player index.
        :param int page_index: The current active page index.

        If ``page_index`` is lower than 0, the page index will be set to 0.

        If ``page_index`` is greater than the last page index, it will be set
        to the last page index.
        """
        page = self._player_pages[player_index]
        if page_index < 0:
            page.index = 0
        elif page_index > self.last_page_index:
            page.index = self.last_page_index
        else:
            page.index = page_index

    def get_player_page(self, player_index):
        """Return the current player page index.

        :param int player_index: A player index.
        """
        return self._player_pages[player_index].index


[docs]class Text(_MenuData): """Display plain text.""" def _render(self, player_index, choice_index=None): """See :meth:`_MenuData._render`.""" return str(_translate_text(self.text, player_index)) + '\n'
class _BaseOption(_MenuData): """This class is used to display an enumerated option.""" def __init__(self, text, value=None, highlight=True, selectable=True): """Initialize the option. :param str text: The text that should be displayed. :param value: The value that should be passed to the menu's selection callback. :param bool highlight: Set this to true if the text should be highlighted. :param bool selectable: Set this to True if the option should be selectable. """ super().__init__(text) self.value = value self.highlight = highlight self.selectable = selectable def _render(self, player_index, choice_index=None): """See :meth:`_MenuData._render`.""" raise NotImplementedError # ============================================================================= # >> HELPER FUNCTIONS # ============================================================================= def _translate_text(text, player_index): """Translate the given ``text``. Only translate if ``text`` is an instance of :class:`translations.strings.TranslationStrings`. Otherwise the original text will be returned. """ if isinstance(text, TranslationStrings): return text.get_string(get_client_language(player_index)) return text # ============================================================================= # >> LISTENERS # ============================================================================= @OnClientDisconnect def on_player_disconnect(player_index): """Called whenever a player left the server.""" for instance in _BaseMenu._instances.values(): instance._unload_player(player_index)