Skip to content

Commit

Permalink
Initial menu implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
hypergonial committed Dec 11, 2023
1 parent ed6a1c9 commit 1222e71
Show file tree
Hide file tree
Showing 19 changed files with 1,101 additions and 37 deletions.
106 changes: 106 additions & 0 deletions examples/menu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import hikari
import miru
from miru.ext import menu

# The `menu` extension is designed to make creating complex nested menus easy via Discord components.
#
# - The `Menu` class stores a stack of `Screen` instances, which are used to display components.
# - The `Screen` is essentially just a container for a `ScreenContent` and a list of `ScreenItem`s.
# - The `ScreenContent` is used to store the message payload (content, embeds etc.) of the screen.


class MainScreen(menu.Screen):
# This method must be overridden in your screen classes
# This is where you would fetch data from a database, etc. to display on your screen
async def build_content(self) -> menu.ScreenContent:
return menu.ScreenContent(
embed=hikari.Embed(
title="Welcome to the Miru Menu example!",
description="This is an example of the Miru Menu extension.",
color=0x00FF00,
),
)

@menu.button(label="Moderation")
async def moderation(self, button: menu.ScreenButton, ctx: miru.Context) -> None:
# Add a new screen to the menu stack, the message is updated automatically
await self.menu.push(ModerationScreen(self.menu))

@menu.button(label="Logging")
async def fun(self, button: menu.ScreenButton, ctx: miru.Context) -> None:
await self.menu.push(LoggingScreen(self.menu))

class ModerationScreen(menu.Screen):
async def build_content(self) -> menu.ScreenContent:
return menu.ScreenContent(
embed=hikari.Embed(
title="Moderation",
description="This is the moderation screen!",
color=0x00FF00,
),
)

@menu.button(label="Back")
async def back(self, button: menu.ScreenButton, ctx: miru.Context) -> None:
# Remove the current screen from the menu stack,
# effectively going back to the previous screen
await self.menu.pop()

@menu.button(label="Ban", style=hikari.ButtonStyle.DANGER)
async def ban(self, button: menu.ScreenButton, ctx: miru.Context) -> None:
await ctx.respond("Hammer time!")

@menu.button(label="Kick", style=hikari.ButtonStyle.SECONDARY)
async def kick(self, button: menu.ScreenButton, ctx: miru.Context) -> None:
await ctx.respond("Kick!")

class LoggingScreen(menu.Screen):
def __init__(self, menu: menu.Menu) -> None:
super().__init__(menu)
# Your screens can store state in the class instance
# But keep in mind that the instance will be
# destroyed once the screen is popped off the stack
self.is_enabled = False

async def build_content(self) -> menu.ScreenContent:
return menu.ScreenContent(
embed=hikari.Embed(
title="Logging",
description="This is the logging screen!",
color=0x00FF00,
),
)


@menu.button(label="Back")
async def back(self, button: menu.ScreenButton, ctx: miru.Context) -> None:
await self.menu.pop()

@menu.button(label="Enable", style=hikari.ButtonStyle.DANGER)
async def enable(self, button: menu.ScreenButton, ctx: miru.Context) -> None:
self.is_enabled = not self.is_enabled
button.style = hikari.ButtonStyle.SUCCESS if self.is_enabled else hikari.ButtonStyle.DANGER
button.label = "Disable" if self.is_enabled else "Enable"
await self.menu.update_message()

bot = hikari.GatewayBot("...")
miru.install(bot) # Start miru


@bot.listen()
async def buttons(event: hikari.GuildMessageCreateEvent) -> None:

# Do not process messages from bots or webhooks
if not event.is_human:
return

me = bot.get_me()

# If the bot is mentioned
if me.id in event.message.user_mentions_ids:
my_menu = menu.Menu() # Create a new Menu
# Specify the starting screen and where to send the menu to
await my_menu.send(MainScreen(my_menu), event.channel_id)


bot.run()
2 changes: 1 addition & 1 deletion miru/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"get_view",
)

__version__ = "3.3.1"
__version__ = "3.4.0"

# MIT License
#
Expand Down
2 changes: 1 addition & 1 deletion miru/abc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from .item import *
from .item_handler import *

__all__ = ("Item", "ItemHandler", "ViewItem", "ModalItem", "DecoratedItem")
__all__ = ("Item", "ItemHandler", "ViewItem", "ModalItem", "DecoratedItem", "ItemArranger")

# MIT License
#
Expand Down
7 changes: 4 additions & 3 deletions miru/abc/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
__all__ = ("Item", "DecoratedItem", "ViewItem", "ModalItem")

BuilderT = t.TypeVar("BuilderT", bound=hikari.api.ComponentBuilder)
ViewItemT = t.TypeVar("ViewItemT", bound="ViewItem")


class Item(abc.ABC, t.Generic[BuilderT]):
Expand Down Expand Up @@ -245,16 +246,16 @@ def _build(self, action_row: hikari.api.ModalActionRowBuilder) -> None:
...


class DecoratedItem:
class DecoratedItem(t.Generic[ViewItemT]):
"""A partial item made using a decorator."""

__slots__ = ("item", "callback")

def __init__(self, item: ViewItem, callback: t.Callable[..., t.Any]) -> None:
def __init__(self, item: ViewItemT, callback: t.Callable[..., t.Any]) -> None:
self.item = item
self.callback = callback

def build(self, view: View) -> ViewItem:
def build(self, view: View) -> ViewItemT:
"""Convert a DecoratedItem into a ViewItem.
Parameters
Expand Down
45 changes: 36 additions & 9 deletions miru/abc/item_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,19 @@
from ..context import Context
from ..events import EventHandler

__all__ = ("ItemHandler",)
__all__ = ("ItemHandler", "ItemArranger")


BuilderT = t.TypeVar("BuilderT", bound=hikari.api.ComponentBuilder)
ContextT = t.TypeVar("ContextT", bound="Context[t.Any]")
ItemT = t.TypeVar("ItemT", bound="Item[t.Any]")


class _Weights(t.Generic[ItemT]):
class ItemArranger(t.Generic[ItemT]):
"""
Calculate the position of an item based on it's width, and keep track of item positions
Calculate the position of an item based on it's width, and automatically arrange items if no explicit row is specified.
Used internally by ItemHandler.
"""

__slots__ = ("_weights",)
Expand All @@ -39,6 +41,22 @@ def __init__(self) -> None:
self._weights = [0, 0, 0, 0, 0]

def add_item(self, item: ItemT) -> None:
"""Add an item to the weights.
Parameters
----------
item : ItemT
The item to add.
Raises
------
RowFullError
The item does not fit on the row specified.
This error is only raised if a row is specified explicitly.
HandlerFullError
The item does not fit on any row.
"""

if item.row is not None:
if item.width + self._weights[item.row] > 5:
raise RowFullError(f"Item does not fit on row {item.row}!")
Expand All @@ -54,11 +72,20 @@ def add_item(self, item: ItemT) -> None:
raise HandlerFullError("Item does not fit on this item handler.")

def remove_item(self, item: ItemT) -> None:
"""Remove an item from the weights.
Parameters
----------
item : ItemT
The item to remove.
"""

if item._rendered_row is not None:
self._weights[item._rendered_row] -= item.width
item._rendered_row = None

def clear(self) -> None:
"""Clear the weights, remove all items."""
self._weights = [0, 0, 0, 0, 0]


Expand Down Expand Up @@ -91,7 +118,7 @@ def __init__(self, *, timeout: t.Optional[t.Union[float, int, datetime.timedelta
self._timeout: t.Optional[float] = float(timeout) if timeout else None
self._children: t.List[ItemT] = []

self._weights: _Weights[ItemT] = _Weights()
self._arranger: ItemArranger[ItemT] = ItemArranger()
self._stopped: asyncio.Event = asyncio.Event()
self._timeout_task: t.Optional[asyncio.Task[None]] = None
self._running_tasks: t.MutableSequence[asyncio.Task[t.Any]] = []
Expand Down Expand Up @@ -184,9 +211,9 @@ def add_item(self, item: ItemT) -> te.Self:
ItemHandler already has 25 components attached.
TypeError
Parameter item is not an instance of Item.
RuntimeError
ItemAlreadyAttachedError
The item is already attached to this item handler.
RuntimeError
ItemAlreadyAttachedError
The item is already attached to another item handler.
Returns
Expand All @@ -209,7 +236,7 @@ def add_item(self, item: ItemT) -> te.Self:
f"Item {type(item).__name__} is already attached to another item handler: {type(item._handler).__name__}."
)

self._weights.add_item(item)
self._arranger.add_item(item)

item._handler = self
self._children.append(item)
Expand All @@ -234,7 +261,7 @@ def remove_item(self, item: ItemT) -> te.Self:
except ValueError:
pass
else:
self._weights.remove_item(item)
self._arranger.remove_item(item)
item._handler = None

return self
Expand All @@ -252,7 +279,7 @@ def clear_items(self) -> te.Self:
item._rendered_row = None

self._children.clear()
self._weights.clear()
self._arranger.clear()

return self

Expand Down
8 changes: 4 additions & 4 deletions miru/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def button(
emoji: t.Optional[t.Union[str, hikari.Emoji]] = None,
row: t.Optional[int] = None,
disabled: bool = False,
) -> t.Callable[[t.Callable[[ViewT, Button, ViewContextT], t.Awaitable[None]]], DecoratedItem]:
) -> t.Callable[[t.Callable[[ViewT, Button, ViewContextT], t.Awaitable[None]]], DecoratedItem[Button]]:
"""A decorator to transform a coroutine function into a Discord UI Button's callback.
This must be inside a subclass of View.
Expand All @@ -195,11 +195,11 @@ def button(
Returns
-------
Callable[[CallableT], CallableT]
The decorated callback coroutine function.
Callable[[Callable[[ViewT, Button, ViewContextT], Awaitable[None]]], DecoratedItem[Button]]
The decorated callback function.
"""

def decorator(func: t.Callable[[ViewT, Button, ViewContextT], t.Awaitable[None]]) -> DecoratedItem:
def decorator(func: t.Callable[[ViewT, Button, ViewContextT], t.Awaitable[None]]) -> DecoratedItem[Button]:
if not inspect.iscoroutinefunction(func):
raise TypeError("button must decorate coroutine function.")
item = Button(label=label, custom_id=custom_id, style=style, emoji=emoji, row=row, disabled=disabled, url=None)
Expand Down
13 changes: 13 additions & 0 deletions miru/ext/menu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# miru menus

## Installation

`miru.ext.menu` is already included with your miru installation, if you installed `hikari-miru`, simply import it to get started.

## Usage

```py
from miru.ext import menu

# TODO: Usage
```
32 changes: 32 additions & 0 deletions miru/ext/menu/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from .items import (
ScreenButton,
ScreenChannelSelect,
ScreenItem,
ScreenRoleSelect,
ScreenTextSelect,
ScreenUserSelect,
button,
channel_select,
role_select,
text_select,
user_select,
)
from .menu import Menu
from .screen import Screen, ScreenContent

__all__ = (
"Menu",
"Screen",
"ScreenContent",
"ScreenItem",
"ScreenButton",
"ScreenTextSelect",
"ScreenChannelSelect",
"ScreenRoleSelect",
"ScreenUserSelect",
"button",
"text_select",
"channel_select",
"role_select",
"user_select",
)
Loading

0 comments on commit 1222e71

Please sign in to comment.