diff --git a/docs/changelogs/v4.md b/docs/changelogs/v4.md index 25734ede..042af4d8 100644 --- a/docs/changelogs/v4.md +++ b/docs/changelogs/v4.md @@ -18,6 +18,7 @@ This is a major breaking release, adding REST bot support, dependency injection, - **BREAKING:** Raised the minimum supported Python version to **3.10** or greater. - **BREAKING:** Change all `@miru` decorators to take `Context` as their first argument and the item (button/select etc..) as their second. +- **BREAKING:** Seperate link buttons out of `miru.Button` as `miru.LinkButton`. - **BREAKING:** Remove `miru.install()`. Use `miru.Client` instead. - **BREAKING:** Remove `View.start()` and `Modal.start()`. Use `Client.start_view()` and `Client.start_modal()` respectively instead. - **BREAKING:** Remove `NavigatorView.send()`. Use `NavigatorView.build_response()` instead and send the builder. @@ -25,8 +26,8 @@ This is a major breaking release, adding REST bot support, dependency injection, - **BREAKING:** Remove `Menu.send()`. Use `Menu.build_response()` instead and send the builder. - **BREAKING:** Remove `miru.ModalInteractionCreateEvent` and `miru.ComponentInteractionCreateEvent`. Use the unhandled interaction hooks instead. - **BREAKING:** Made `ViewItem.callback` only accept positional arguments. This is to allow renaming the context variable's name when overriding it in subclasses. This should not affect most people. -- **BREAKING:** Move `miru.context.base.Context` to `miru.abc.context.Context`. -- **BREAKING:** Move `miru.select.base.SelectBase` to `miru.abc.select.SelectBase`. +- **BREAKING:** Move `miru.Context` to `miru.abc.Context`. +- **BREAKING:** Move `miru.SelectBase` to `miru.abc.SelectBase`. - **DEPRECATION:** Passing `buttons=` to `ext.nav.NavigatorView()` constructor. Use the newly added `items=` instead. The `buttons=` argument will be removed in v4.2.0. - Add `miru.Client`. The client manages the state of all currently running views & modals & routes interactions to them. @@ -38,3 +39,4 @@ This is a major breaking release, adding REST bot support, dependency injection, - Add response builders for entire responses from views or modals. - Add `Context.respond_with_builder()`. - Add `@Client.set_unhandled_component_interaction_hook` and `@Client.set_unhandled_modal_interaction_hook`. These are called when an interaction is received that is not handled by any running modal or view. +- Add `miru.abc.InteractiveViewItem` for all view items that have callbacks. This includes all current `miru.abc.ViewItem` except `miru.LinkButton`. diff --git a/docs/guides/migrating_from_v3.md b/docs/guides/migrating_from_v3.md index 41038de0..670e58db 100644 --- a/docs/guides/migrating_from_v3.md +++ b/docs/guides/migrating_from_v3.md @@ -107,7 +107,7 @@ For more information on how to use these builders with each of the major command `NavigatorView.send()`, `Menu.send()` have been removed. -Similarly to modals, menus & navigators are also now turned into builders. However, since the payloads are built asynchronously, you need to use [`Menu.build_response_async()`][miru.ext.menu.menu.Menu.build_response_async] and [`NavigatorView.build_response_async()`][miru.ext.nav.NavigatorView.build_response_async] respectively, instead. If you're handling an interaction, you may also need to defer beforehand if building your initial screen takes a long time. +Similarly to modals, menus & navigators are also now turned into builders. However, since the payloads are built asynchronously, you need to use [`Menu.build_response_async()`][miru.ext.menu.menu.Menu.build_response_async] and [`NavigatorView.build_response_async()`][miru.ext.nav.NavigatorView.build_response_async] respectively. If you're handling an interaction, you may also need to defer beforehand depending on how long it takes to build the payload. === "v4" @@ -160,3 +160,19 @@ Similarly to modals, menus & navigators are also now turned into builders. Howev ``` For more information on how to use these builders with each of the major command handler frameworks, please see the updated [menu](./menus.md) and [navigator](./navigators.md) guides. + +## Link buttons + +Link buttons have been seperated out of `miru.Button` and received their own class: `miru.LinkButton`. For your existing link buttons, it should be as simple as updating the class: + +=== "v4" + + ```py + view.add_item(miru.LinkButton(url="https://google.com")) + ``` + +=== "v3" + + ```py + view.add_item(miru.Button(url="https://google.com")) + ``` diff --git a/miru/__init__.py b/miru/__init__.py index 652cef46..b73de05c 100644 --- a/miru/__init__.py +++ b/miru/__init__.py @@ -12,7 +12,7 @@ from miru import abc, ext, select from miru.abc.context import InteractionResponse -from miru.button import Button, button +from miru.button import Button, LinkButton, button from miru.client import Client from miru.context import AutodeferMode, AutodeferOptions, ModalContext, ViewContext from miru.exceptions import HandlerFullError, ItemAlreadyAttachedError, MiruError, RowFullError @@ -48,6 +48,7 @@ "ViewContext", "ModalContext", "Button", + "LinkButton", "button", "MiruError", "RowFullError", diff --git a/miru/abc/__init__.py b/miru/abc/__init__.py index d6bbac82..f91575b4 100644 --- a/miru/abc/__init__.py +++ b/miru/abc/__init__.py @@ -12,6 +12,7 @@ "Item", "ItemHandler", "ViewItem", + "InteractiveViewItem", "ModalItem", "DecoratedItem", "SelectBase", diff --git a/miru/abc/item.py b/miru/abc/item.py index e5a9a564..0e813df1 100644 --- a/miru/abc/item.py +++ b/miru/abc/item.py @@ -19,7 +19,7 @@ from miru.view import View -__all__ = ("Item", "DecoratedItem", "ViewItem", "ModalItem") +__all__ = ("Item", "DecoratedItem", "ViewItem", "InteractiveViewItem", "ModalItem") class Item(abc.ABC, t.Generic[BuilderT, ContextT, HandlerT]): @@ -122,12 +122,10 @@ def __init__( position: int | None = None, width: int = 1, disabled: bool = False, - autodefer: bool | AutodeferOptions | hikari.UndefinedType = hikari.UNDEFINED, ) -> None: super().__init__(custom_id=custom_id, row=row, position=position, width=width) self._handler: View | None = None self._disabled: bool = disabled - self._autodefer = AutodeferOptions.parse(autodefer) if autodefer is not hikari.UNDEFINED else autodefer @property def view(self) -> View: @@ -146,6 +144,36 @@ def disabled(self) -> bool: def disabled(self, value: bool) -> None: self._disabled = value + @abstractmethod + def _build(self, action_row: hikari.api.MessageActionRowBuilder) -> None: + """Called internally to build and append the item to an action row.""" + ... + + @classmethod + @abstractmethod + def _from_component(cls, component: hikari.PartialComponent, row: int | None = None) -> te.Self: + """Converts the passed hikari component into a miru ViewItem.""" + ... + + +class InteractiveViewItem(ViewItem, abc.ABC): + """An abstract base class for view components that have callbacks. + Cannot be directly instantiated. + """ + + def __init__( + self, + *, + custom_id: str | None = None, + row: int | None = None, + position: int | None = None, + width: int = 1, + disabled: bool = False, + autodefer: bool | AutodeferOptions | hikari.UndefinedType = hikari.UNDEFINED, + ) -> None: + super().__init__(custom_id=custom_id, row=row, position=position, width=width, disabled=disabled) + self._autodefer = AutodeferOptions.parse(autodefer) if autodefer is not hikari.UNDEFINED else autodefer + @property def autodefer(self) -> AutodeferOptions | hikari.UndefinedType: """Indicates whether the item should be deferred automatically. @@ -153,11 +181,6 @@ def autodefer(self) -> AutodeferOptions | hikari.UndefinedType: """ return self._autodefer - @abstractmethod - def _build(self, action_row: hikari.api.MessageActionRowBuilder) -> None: - """Called internally to build and append the item to an action row.""" - ... - @classmethod @abstractmethod def _from_component(cls, component: hikari.PartialComponent, row: int | None = None) -> te.Self: diff --git a/miru/abc/select.py b/miru/abc/select.py index f22c0dd1..79c3912f 100644 --- a/miru/abc/select.py +++ b/miru/abc/select.py @@ -5,7 +5,7 @@ import hikari -from miru.abc.item import ViewItem +from miru.abc.item import InteractiveViewItem if t.TYPE_CHECKING: import typing_extensions as te @@ -18,7 +18,7 @@ __all__ = ("SelectBase",) -class SelectBase(ViewItem, abc.ABC): +class SelectBase(InteractiveViewItem, abc.ABC): """A view component representing some type of select menu. All types of selects derive from this class. Parameters diff --git a/miru/button.py b/miru/button.py index de5213be..c5aa1a95 100644 --- a/miru/button.py +++ b/miru/button.py @@ -5,37 +5,36 @@ import hikari -from miru.abc.item import DecoratedItem, ViewItem +from miru.abc.item import DecoratedItem, InteractiveViewItem, ViewItem if t.TYPE_CHECKING: import typing_extensions as te from miru.context import ViewContext from miru.context.view import AutodeferOptions + from miru.internal.types import InteractiveButtonStylesT from miru.view import View ViewT = t.TypeVar("ViewT", bound="View") -__all__ = ("Button", "button") +__all__ = ("Button", "LinkButton", "button") -class Button(ViewItem): - """A view component representing a button. +class Button(InteractiveViewItem): + """A view component representing an interactive button. Parameters ---------- - style : hikari.ButtonStyle - The button's style label : str | None The button's label + emoji : hikari.Emoji | str | None + The emoji present on the button + style : hikari.ButtonStyle + The button's style disabled : bool A boolean determining if the button should be disabled or not custom_id : str | None The custom identifier of the button - url : str | None - The URL of the button - emoji : hikari.Emoji | str | None - The emoji present on the button row : int | None The row the button should be in, leave as None for auto-placement. position : int | None @@ -53,13 +52,12 @@ class Button(ViewItem): def __init__( self, - *, - style: hikari.ButtonStyle = hikari.ButtonStyle.PRIMARY, label: str | None = None, + *, + emoji: hikari.Emoji | str | None = None, + style: InteractiveButtonStylesT = hikari.ButtonStyle.PRIMARY, disabled: bool = False, custom_id: str | None = None, - url: str | None = None, - emoji: hikari.Emoji | str | None = None, row: int | None = None, position: int | None = None, autodefer: bool | AutodeferOptions | hikari.UndefinedType = hikari.UNDEFINED, @@ -67,11 +65,7 @@ def __init__( super().__init__(custom_id=custom_id, row=row, position=position, disabled=disabled, autodefer=autodefer) self._emoji: hikari.Emoji | None = hikari.Emoji.parse(emoji) if isinstance(emoji, str) else emoji self.label = label - self.url = self._url = url - self.style = self._style = style if self._url is None else hikari.ButtonStyle.LINK - - if self._is_persistent and self.url: - raise TypeError("Cannot provide both 'url' and 'custom_id'.") + self.style = style @property def type(self) -> hikari.ComponentType: @@ -83,10 +77,7 @@ def style(self) -> hikari.ButtonStyle: return self._style @style.setter - def style(self, value: hikari.ButtonStyle) -> None: - if self._url is not None and value != hikari.ButtonStyle.LINK: - raise ValueError("A link button cannot have it's style changed. Set 'url' to 'None' to change the style.") - + def style(self, value: InteractiveButtonStylesT) -> None: self._style = value @property @@ -107,35 +98,25 @@ def emoji(self) -> hikari.Emoji | None: @emoji.setter def emoji(self, value: str | hikari.Emoji | None) -> None: - if value and isinstance(value, str): + if isinstance(value, str): value = hikari.Emoji.parse(value) - self._emoji = value # type: ignore [assignment] - - @property - def url(self) -> str | None: - """The button's URL. If specified, the button will turn into a link button, - and the style parameter will be ignored. - """ - return self._url - - @url.setter - def url(self, value: str | None) -> None: - if value: - self._style = hikari.ButtonStyle.LINK - - self._url = value + self._emoji = value @classmethod def _from_component(cls, component: hikari.PartialComponent, row: int | None = None) -> te.Self: assert isinstance(component, hikari.ButtonComponent) + style = hikari.ButtonStyle(component.style) + + if style is hikari.ButtonStyle.LINK: + raise ValueError(f"Cannot create '{cls.__name__}' from link button.") + return cls( - style=hikari.ButtonStyle(component.style), + style=hikari.ButtonStyle(component.style), # type: ignore label=component.label, disabled=component.is_disabled, custom_id=component.custom_id, - url=component.url, emoji=component.emoji, row=row, ) @@ -144,26 +125,128 @@ def _build(self, action_row: hikari.api.MessageActionRowBuilder) -> None: if self.emoji is None and self.label is None: raise TypeError("Must provide at least one of 'emoji' or 'label' when building Button.") - if self.url is not None: - action_row.add_link_button( - self.url, emoji=self.emoji or hikari.UNDEFINED, label=self.label or hikari.UNDEFINED - ) - else: - action_row.add_interactive_button( - self.style if self.style is not hikari.ButtonStyle.LINK else hikari.ButtonStyle.PRIMARY, - self.custom_id, - emoji=self.emoji or hikari.UNDEFINED, - label=self.label or hikari.UNDEFINED, - is_disabled=self.disabled, - ) + action_row.add_interactive_button( + self.style if self.style is not hikari.ButtonStyle.LINK else hikari.ButtonStyle.PRIMARY, + self.custom_id, + emoji=self.emoji or hikari.UNDEFINED, + label=self.label or hikari.UNDEFINED, + is_disabled=self.disabled, + ) + + +class LinkButton(ViewItem): + """A view component representing a link button. + + Parameters + ---------- + url : str | None + The URL of the button + label : str | None + The button's label + emoji : hikari.Emoji | str | None + The emoji present on the button + disabled : bool + A boolean determining if the button should be disabled or not + row : int | None + The row the button should be in, leave as None for auto-placement. + position : int | None + The position the button should be in within a row, leave as None for auto-placement. + + Raises + ------ + TypeError + If both label and emoji are left empty. + TypeError + if both custom_id and url are provided. + """ + + def __init__( + self, + url: str, + label: str | None = None, + *, + emoji: hikari.Emoji | str | None = None, + disabled: bool = False, + row: int | None = None, + position: int | None = None, + ) -> None: + super().__init__(row=row, position=position, disabled=disabled) + self._emoji: hikari.Emoji | None = hikari.Emoji.parse(emoji) if isinstance(emoji, str) else emoji + self.label = label + self._url = url + + @property + def type(self) -> hikari.ComponentType: + return hikari.ComponentType.BUTTON + + @property + def style(self) -> t.Literal[hikari.ButtonStyle.LINK]: + """The button's style.""" + return hikari.ButtonStyle.LINK + + @property + def label(self) -> str | None: + """The button's label. This is the text visible on the button.""" + return self._label + + @label.setter + def label(self, value: str | None) -> None: + if value is not None and len(value) > 80: + raise ValueError(f"Parameter 'label' must be 80 or fewer in length. (Found {len(value)})") + self._label = str(value) if value else None + + @property + def emoji(self) -> hikari.Emoji | None: + """The emoji that should be visible on the button.""" + return self._emoji + + @emoji.setter + def emoji(self, value: str | hikari.Emoji | None) -> None: + if isinstance(value, str): + value = hikari.Emoji.parse(value) + + self._emoji = value + + @property + def url(self) -> str: + """The URL of the button.""" + return self._url + + @url.setter + def url(self, value: str) -> None: + self._url = str(value) + + @classmethod + def _from_component(cls, component: hikari.PartialComponent, row: int | None = None) -> te.Self: + assert isinstance(component, hikari.ButtonComponent) + + style = hikari.ButtonStyle(component.style) + + if style is not hikari.ButtonStyle.LINK or component.url is None: + raise ValueError(f"Cannot create '{cls.__name__}' from interactive button.") + + return cls( + label=component.label, disabled=component.is_disabled, url=component.url, emoji=component.emoji, row=row + ) + + def _build(self, action_row: hikari.api.MessageActionRowBuilder) -> None: + if self.emoji is None and self.label is None: + raise TypeError("Must provide at least one of 'emoji' or 'label' when building LinkButton.") + + action_row.add_link_button( + self.url, + emoji=self.emoji or hikari.UNDEFINED, + label=self.label or hikari.UNDEFINED, + is_disabled=self.disabled, + ) def button( - *, label: str | None = None, - custom_id: str | None = None, - style: hikari.ButtonStyle = hikari.ButtonStyle.PRIMARY, + *, emoji: str | hikari.Emoji | None = None, + custom_id: str | None = None, + style: InteractiveButtonStylesT = hikari.ButtonStyle.PRIMARY, row: int | None = None, disabled: bool = False, autodefer: bool | AutodeferOptions | hikari.UndefinedType = hikari.UNDEFINED, @@ -175,12 +258,12 @@ def button( ---------- label : str | None The button's label + emoji : str | hikari.Emoji | None + The emoji shown on the button custom_id : str | None The button's custom identifier - style : hikari.ButtonStyle + style : InteractiveButtonStylesT The style of the button - emoji : str | hikari.Emoji | None - The emoji shown on the button row : int | None The row the button should be in, leave as None for auto-placement. disabled : bool @@ -198,14 +281,7 @@ def decorator(func: t.Callable[[ViewT, ViewContext, Button], t.Awaitable[None]]) if not inspect.iscoroutinefunction(func): raise TypeError("'@button' must decorate coroutine function.") item: Button = Button( - label=label, - custom_id=custom_id, - style=style, - emoji=emoji, - row=row, - disabled=disabled, - url=None, - autodefer=autodefer, + label=label, custom_id=custom_id, style=style, emoji=emoji, row=row, disabled=disabled, autodefer=autodefer ) return DecoratedItem(item, func) diff --git a/miru/ext/menu/__init__.py b/miru/ext/menu/__init__.py index 8b1550fd..144f8151 100644 --- a/miru/ext/menu/__init__.py +++ b/miru/ext/menu/__init__.py @@ -1,4 +1,5 @@ from .items import ( + InteractiveScreenItem, ScreenButton, ScreenChannelSelect, ScreenItem, @@ -19,6 +20,7 @@ "Screen", "ScreenContent", "ScreenItem", + "InteractiveScreenItem", "ScreenButton", "ScreenTextSelect", "ScreenChannelSelect", diff --git a/miru/ext/menu/items.py b/miru/ext/menu/items.py index 0a2592e0..b435427a 100644 --- a/miru/ext/menu/items.py +++ b/miru/ext/menu/items.py @@ -7,14 +7,15 @@ import hikari import miru -from miru.abc import ViewItem +from miru.ext.menu.menu import Menu if t.TYPE_CHECKING: - from miru.ext.menu.menu import Menu from miru.ext.menu.screen import Screen + from miru.internal.types import InteractiveButtonStylesT __all__ = ( "ScreenItem", + "InteractiveScreenItem", "ScreenButton", "ScreenTextSelect", "ScreenUserSelect", @@ -30,11 +31,13 @@ ) ScreenT = t.TypeVar("ScreenT", bound="Screen") -ScreenItemT = t.TypeVar("ScreenItemT", bound="ScreenItem") +ScreenItemT = t.TypeVar("ScreenItemT", bound="InteractiveScreenItem") -class ScreenItem(ViewItem, abc.ABC): - """A base class for all screen items. Screen requires instances of this class as it's items.""" +class ScreenItem(miru.abc.ViewItem, abc.ABC): + """An abstract base for all screen items. + [`Screen`][miru.ext.menu.screen.Screen] requires instances of this class as it's items. + """ def __init__( self, @@ -44,55 +47,56 @@ def __init__( position: int | None = None, disabled: bool = False, width: int = 1, - autodefer: bool | miru.AutodeferOptions | hikari.UndefinedType = hikari.UNDEFINED, ) -> None: - super().__init__( - custom_id=custom_id, row=row, width=width, position=position, disabled=disabled, autodefer=autodefer - ) - self._handler: Menu | None = None # type: ignore + super().__init__(custom_id=custom_id, row=row, width=width, position=position, disabled=disabled) self._screen: Screen | None = None - @property - def view(self) -> Menu: - """The view this item is attached to.""" - if not self._handler: - raise AttributeError(f"{type(self).__name__} hasn't been attached to a view yet") - return self._handler - @property def menu(self) -> Menu: - """The menu this item is attached to. Alias for `view`.""" + """The menu this item is attached to. + This will be the same as `view` if the view is a menu. + """ + if not isinstance(self.view, Menu): + raise AttributeError(f"{type(self).__name__} hasn't been attached to a menu.") return self.view @property def screen(self) -> Screen: """The screen this item is attached to.""" if not self._screen: - raise AttributeError(f"{type(self).__name__} hasn't been attached to a screen yet") + raise AttributeError(f"{type(self).__name__} hasn't been attached to a screen yet.") return self._screen -class ScreenButton(miru.Button, ScreenItem): +class InteractiveScreenItem(miru.abc.InteractiveViewItem, ScreenItem): + """An abstract base for all interactive screen items.""" + + +class ScreenLinkButton(miru.LinkButton, ScreenItem): + """A base class for all screen link buttons.""" + + +class ScreenButton(miru.Button, InteractiveScreenItem): """A base class for all screen buttons.""" -class ScreenTextSelect(miru.TextSelect, ScreenItem): +class ScreenTextSelect(miru.TextSelect, InteractiveScreenItem): """A base class for all screen text selects.""" -class ScreenUserSelect(miru.UserSelect, ScreenItem): +class ScreenUserSelect(miru.UserSelect, InteractiveScreenItem): """A base class for all screen user selects.""" -class ScreenRoleSelect(miru.RoleSelect, ScreenItem): +class ScreenRoleSelect(miru.RoleSelect, InteractiveScreenItem): """A base class for all screen role selects.""" -class ScreenChannelSelect(miru.ChannelSelect, ScreenItem): +class ScreenChannelSelect(miru.ChannelSelect, InteractiveScreenItem): """A base class for all screen channel selects.""" -class ScreenMentionableSelect(miru.MentionableSelect, ScreenItem): +class ScreenMentionableSelect(miru.MentionableSelect, InteractiveScreenItem): """A base class for all screen mentionable selects.""" @@ -145,7 +149,7 @@ def button( *, label: str | None = None, custom_id: str | None = None, - style: hikari.ButtonStyle = hikari.ButtonStyle.PRIMARY, + style: InteractiveButtonStylesT = hikari.ButtonStyle.PRIMARY, emoji: str | hikari.Emoji | None = None, row: int | None = None, disabled: bool = False, @@ -155,7 +159,7 @@ def button( DecoratedScreenItem[ScreenT, ScreenButton], ]: """A decorator to transform a coroutine function into a Discord UI Button's callback. - This must be inside a subclass of Screen. + This must be inside a subclass of [`Screen`][miru.ext.menu.screen.Screen]. Parameters ---------- @@ -189,16 +193,9 @@ def decorator( func: t.Callable[[ScreenT, miru.ViewContext, ScreenButton], t.Awaitable[None]], ) -> DecoratedScreenItem[ScreenT, ScreenButton]: if not inspect.iscoroutinefunction(func): - raise TypeError("button must decorate coroutine function.") + raise TypeError("'@button' must decorate coroutine function.") item: ScreenButton = ScreenButton( - label=label, - custom_id=custom_id, - style=style, - emoji=emoji, - row=row, - disabled=disabled, - url=None, - autodefer=autodefer, + label=label, custom_id=custom_id, style=style, emoji=emoji, row=row, disabled=disabled, autodefer=autodefer ) return DecoratedScreenItem(item, func) @@ -221,7 +218,7 @@ def channel_select( DecoratedScreenItem[ScreenT, ScreenChannelSelect], ]: """A decorator to transform a function into a Discord UI ChannelSelectMenu's callback. - This must be inside a subclass of Screen. + This must be inside a subclass of [`Screen`][miru.ext.menu.screen.Screen]. Parameters ---------- @@ -257,7 +254,7 @@ def decorator( func: t.Callable[[ScreenT, miru.ViewContext, ScreenChannelSelect], t.Awaitable[None]], ) -> DecoratedScreenItem[ScreenT, ScreenChannelSelect]: if not inspect.iscoroutinefunction(func): - raise TypeError("channel_select must decorate coroutine function.") + raise TypeError("'@channel_select' must decorate coroutine function.") item: ScreenChannelSelect = ScreenChannelSelect( channel_types=channel_types, @@ -288,7 +285,7 @@ def mentionable_select( DecoratedScreenItem[ScreenT, ScreenMentionableSelect], ]: """A decorator to transform a function into a Discord UI MentionableSelectMenu's callback. - This must be inside a subclass of Screen. + This must be inside a subclass of [`Screen`][miru.ext.menu.screen.Screen]. Parameters ---------- @@ -322,7 +319,7 @@ def decorator( func: t.Callable[[ScreenT, miru.ViewContext, ScreenMentionableSelect], t.Awaitable[None]], ) -> DecoratedScreenItem[ScreenT, ScreenMentionableSelect]: if not inspect.iscoroutinefunction(func): - raise TypeError("mentionable_select must decorate coroutine function.") + raise TypeError("'@mentionable_select' must decorate coroutine function.") item: ScreenMentionableSelect = ScreenMentionableSelect( custom_id=custom_id, @@ -352,7 +349,7 @@ def role_select( DecoratedScreenItem[ScreenT, ScreenRoleSelect], ]: """A decorator to transform a function into a Discord UI RoleSelectMenu's callback. - This must be inside a subclass of Screen. + This must be inside a subclass of [`Screen`][miru.ext.menu.screen.Screen]. Parameters ---------- @@ -386,7 +383,7 @@ def decorator( func: t.Callable[[ScreenT, miru.ViewContext, ScreenRoleSelect], t.Awaitable[None]], ) -> DecoratedScreenItem[ScreenT, ScreenRoleSelect]: if not inspect.iscoroutinefunction(func): - raise TypeError("role_select must decorate coroutine function.") + raise TypeError("'@role_select' must decorate coroutine function.") item: ScreenRoleSelect = ScreenRoleSelect( custom_id=custom_id, @@ -417,7 +414,7 @@ def text_select( DecoratedScreenItem[ScreenT, ScreenTextSelect], ]: """A decorator to transform a function into a Discord UI TextSelectMenu's callback. - This must be inside a subclass of Screen. + This must be inside a subclass of [`Screen`][miru.ext.menu.screen.Screen]. Parameters ---------- @@ -448,7 +445,7 @@ def decorator( func: t.Callable[[ScreenT, miru.ViewContext, ScreenTextSelect], t.Awaitable[None]], ) -> DecoratedScreenItem[ScreenT, ScreenTextSelect]: if not inspect.iscoroutinefunction(func): - raise TypeError("text_select must decorate coroutine function.") + raise TypeError("'@text_select' must decorate coroutine function.") item: ScreenTextSelect = ScreenTextSelect( options=options, @@ -479,7 +476,7 @@ def user_select( DecoratedScreenItem[ScreenT, ScreenUserSelect], ]: """A decorator to transform a function into a Discord UI UserSelectMenu's callback. - This must be inside a subclass of Screen. + This must be inside a subclass of [`Screen`][miru.ext.menu.screen.Screen]. Parameters ---------- @@ -508,7 +505,7 @@ def decorator( func: t.Callable[[ScreenT, miru.ViewContext, ScreenUserSelect], t.Awaitable[None]], ) -> DecoratedScreenItem[ScreenT, ScreenUserSelect]: if not inspect.iscoroutinefunction(func): - raise TypeError("user_select must decorate coroutine function.") + raise TypeError("'@user_select' must decorate coroutine function.") item: ScreenUserSelect = ScreenUserSelect( custom_id=custom_id, diff --git a/miru/ext/menu/menu.py b/miru/ext/menu/menu.py index 8f528dbb..0ee66df7 100644 --- a/miru/ext/menu/menu.py +++ b/miru/ext/menu/menu.py @@ -6,12 +6,11 @@ import hikari import miru -from miru.response import MessageBuilder if t.TYPE_CHECKING: import datetime - from .screen import Screen, ScreenContent + from miru.ext.menu.screen import Screen, ScreenContent logger = logging.getLogger(__name__) @@ -29,7 +28,12 @@ class Menu(miru.View): If enabled, interactions will be automatically deferred if not responded to within 2 seconds """ - def __init__(self, *, timeout: float | int | datetime.timedelta | None = 300.0, autodefer: bool = True): + def __init__( + self, + *, + timeout: float | int | datetime.timedelta | None = 300.0, + autodefer: bool | miru.AutodeferOptions = True, + ): super().__init__(timeout=timeout, autodefer=autodefer) self._stack: list[Screen] = [] # The interaction that was used to send the menu, if any. @@ -165,9 +169,13 @@ async def pop_until_root(self) -> None: async def build_response_async( self, client: miru.Client, starting_screen: Screen, *, ephemeral: bool = False - ) -> MessageBuilder: + ) -> miru.MessageBuilder: """Create a REST response builder out of this Menu. + !!! tip + If it takes too long to build the starting screen, you may want to + defer the interaction before calling this method. + Parameters ---------- client : Client @@ -180,10 +188,11 @@ async def build_response_async( if self._client is not None: raise RuntimeError("Navigator is already bound to a client.") + self._stack.append(starting_screen) await self._load_screen(starting_screen) self._ephemeral = ephemeral - builder = MessageBuilder(hikari.ResponseType.MESSAGE_CREATE, **self._payload) + builder = miru.MessageBuilder(hikari.ResponseType.MESSAGE_CREATE, components=self, **self._payload) builder._client = client return builder diff --git a/miru/ext/menu/screen.py b/miru/ext/menu/screen.py index 80d3ddd7..0546314c 100644 --- a/miru/ext/menu/screen.py +++ b/miru/ext/menu/screen.py @@ -8,7 +8,7 @@ import hikari from miru.exceptions import HandlerFullError, ItemAlreadyAttachedError -from miru.ext.menu.items import DecoratedScreenItem, ScreenItem +from miru.ext.menu.items import DecoratedScreenItem, InteractiveScreenItem, ScreenItem if t.TYPE_CHECKING: import typing_extensions as te @@ -67,12 +67,12 @@ class Screen(abc.ABC): """ _screen_children: t.Sequence[ - DecoratedScreenItem[te.Self, ScreenItem] + DecoratedScreenItem[te.Self, InteractiveScreenItem] ] = [] # Decorated callbacks that need to be turned into items def __init_subclass__(cls) -> None: """Get decorated callbacks.""" - children: t.MutableSequence[DecoratedScreenItem[te.Self, ScreenItem]] = [] + children: t.MutableSequence[DecoratedScreenItem[te.Self, InteractiveScreenItem]] = [] for base_cls in reversed(cls.mro()): for value in base_cls.__dict__.values(): if isinstance(value, DecoratedScreenItem): diff --git a/miru/ext/nav/__init__.py b/miru/ext/nav/__init__.py index a76eb3a5..6ea60b8d 100644 --- a/miru/ext/nav/__init__.py +++ b/miru/ext/nav/__init__.py @@ -1,6 +1,7 @@ from .items import ( FirstButton, IndicatorButton, + InteractiveNavItem, LastButton, NavButton, NavChannelSelect, @@ -18,6 +19,7 @@ __all__ = ( "NavItem", + "InteractiveNavItem", "NavButton", "NavTextSelect", "NavChannelSelect", diff --git a/miru/ext/nav/items.py b/miru/ext/nav/items.py index 3c1688da..3d093413 100644 --- a/miru/ext/nav/items.py +++ b/miru/ext/nav/items.py @@ -5,15 +5,16 @@ import hikari -from miru.abc.item import ViewItem -from miru.button import Button +from miru.abc.item import InteractiveViewItem, ViewItem +from miru.button import Button, LinkButton from miru.modal import Modal from miru.select import ChannelSelect, MentionableSelect, RoleSelect, TextSelect, UserSelect from miru.text_input import TextInput if t.TYPE_CHECKING: - from miru.context.view import AutodeferOptions, ViewContext + from miru.context.view import ViewContext from miru.ext.nav.navigator import NavigatorView + from miru.internal.types import InteractiveButtonStylesT __all__ = ( "NavItem", @@ -33,7 +34,7 @@ class NavItem(ViewItem, abc.ABC): - """A baseclass for all navigation items. NavigatorView requires instances of this class as it's items.""" + """An abstract base for all navigation items. NavigatorView requires instances of this class as it's items.""" def __init__( self, @@ -43,11 +44,8 @@ def __init__( position: int | None = None, disabled: bool = False, width: int = 1, - autodefer: bool | AutodeferOptions | hikari.UndefinedType = hikari.UNDEFINED, ) -> None: - super().__init__( - custom_id=custom_id, row=row, width=width, position=position, disabled=disabled, autodefer=autodefer - ) + super().__init__(custom_id=custom_id, row=row, width=width, position=position, disabled=disabled) self._handler: NavigatorView | None = None # type: ignore async def before_page_change(self) -> None: @@ -64,27 +62,35 @@ def view(self) -> NavigatorView: return self._handler -class NavButton(Button, NavItem): +class InteractiveNavItem(InteractiveViewItem, NavItem): + """An abstract base for all interactive navigation items.""" + + +class NavLinkButton(LinkButton, NavItem): + """A base class for all navigation link buttons.""" + + +class NavButton(Button, InteractiveNavItem): """A base class for all navigation buttons.""" -class NavTextSelect(TextSelect, NavItem): +class NavTextSelect(TextSelect, InteractiveNavItem): """A base class for all navigation text selects.""" -class NavUserSelect(UserSelect, NavItem): +class NavUserSelect(UserSelect, InteractiveNavItem): """A base class for all navigation user selects.""" -class NavRoleSelect(RoleSelect, NavItem): +class NavRoleSelect(RoleSelect, InteractiveNavItem): """A base class for all navigation role selects.""" -class NavChannelSelect(ChannelSelect, NavItem): +class NavChannelSelect(ChannelSelect, InteractiveNavItem): """A base class for all navigation channel selects.""" -class NavMentionableSelect(MentionableSelect, NavItem): +class NavMentionableSelect(MentionableSelect, InteractiveNavItem): """A base class for all navigation mentionable selects.""" @@ -94,7 +100,7 @@ class NextButton(NavButton): def __init__( self, *, - style: hikari.ButtonStyle = hikari.ButtonStyle.PRIMARY, + style: InteractiveButtonStylesT = hikari.ButtonStyle.PRIMARY, label: str | None = None, custom_id: str | None = None, emoji: hikari.Emoji | str | None = chr(9654), @@ -120,7 +126,7 @@ class PrevButton(NavButton): def __init__( self, *, - style: hikari.ButtonStyle = hikari.ButtonStyle.PRIMARY, + style: InteractiveButtonStylesT = hikari.ButtonStyle.PRIMARY, label: str | None = None, custom_id: str | None = None, emoji: hikari.Emoji | str | None = chr(9664), @@ -146,7 +152,7 @@ class FirstButton(NavButton): def __init__( self, *, - style: hikari.ButtonStyle = hikari.ButtonStyle.PRIMARY, + style: InteractiveButtonStylesT = hikari.ButtonStyle.PRIMARY, label: str | None = None, custom_id: str | None = None, emoji: hikari.Emoji | str | None = chr(9194), @@ -172,7 +178,7 @@ class LastButton(NavButton): def __init__( self, *, - style: hikari.ButtonStyle = hikari.ButtonStyle.PRIMARY, + style: InteractiveButtonStylesT = hikari.ButtonStyle.PRIMARY, label: str | None = None, custom_id: str | None = None, emoji: hikari.Emoji | str | None = chr(9193), @@ -198,7 +204,7 @@ class IndicatorButton(NavButton): def __init__( self, *, - style: hikari.ButtonStyle = hikari.ButtonStyle.SECONDARY, + style: InteractiveButtonStylesT = hikari.ButtonStyle.SECONDARY, custom_id: str | None = None, emoji: hikari.Emoji | str | None = None, disabled: bool = False, @@ -242,7 +248,7 @@ class StopButton(NavButton): def __init__( self, *, - style: hikari.ButtonStyle = hikari.ButtonStyle.DANGER, + style: InteractiveButtonStylesT = hikari.ButtonStyle.DANGER, label: str | None = None, custom_id: str | None = None, emoji: hikari.Emoji | str | None = chr(9209), diff --git a/miru/ext/nav/navigator.py b/miru/ext/nav/navigator.py index 2039661a..4f1c5b35 100644 --- a/miru/ext/nav/navigator.py +++ b/miru/ext/nav/navigator.py @@ -18,7 +18,7 @@ from miru.abc.context import Context from miru.client import Client - from miru.ext.nav.items import ViewItem + from miru.context.view import AutodeferOptions logger = logging.getLogger(__name__) @@ -36,7 +36,7 @@ class NavigatorView(View): A list of navigation buttons to override the default ones with timeout : float | int | datetime.timedelta | None The duration after which the view times out, in seconds - autodefer : bool + autodefer : bool | AutodeferOptions If enabled, interactions will be automatically deferred if not responded to within 2 seconds Raises @@ -52,7 +52,7 @@ def __init__( pages: t.Sequence[str | hikari.Embed | t.Sequence[hikari.Embed] | Page], items: t.Sequence[NavItem] | None = None, timeout: float | int | datetime.timedelta | None = 120.0, - autodefer: bool = True, + autodefer: bool | AutodeferOptions = True, ) -> None: ... @@ -64,7 +64,7 @@ def __init__( pages: t.Sequence[str | hikari.Embed | t.Sequence[hikari.Embed] | Page], buttons: t.Sequence[NavButton] | None = None, timeout: float | int | datetime.timedelta | None = 120.0, - autodefer: bool = True, + autodefer: bool | AutodeferOptions = True, ) -> None: ... @@ -75,7 +75,7 @@ def __init__( buttons: t.Sequence[NavButton] | None = None, items: t.Sequence[NavItem] | None = None, timeout: float | int | datetime.timedelta | None = 120.0, - autodefer: bool = True, + autodefer: bool | AutodeferOptions = True, ) -> None: self._pages: t.Sequence[str | hikari.Embed | t.Sequence[hikari.Embed] | Page] = pages self._current_page: int = 0 @@ -144,12 +144,12 @@ def get_default_buttons(self) -> t.Sequence[NavButton]: Returns ------- - List[NavButton[NavigatorViewT]] + List[NavButton] A list of the default navigation buttons. """ return [FirstButton(), PrevButton(), IndicatorButton(), NextButton(), LastButton()] - def add_item(self, item: ViewItem) -> te.Self: + def add_item(self, item: NavItem) -> te.Self: # pyright: ignore reportIncompatibleMethodOverride """Adds a new item to the navigator. Item must be of type NavItem. Parameters @@ -167,11 +167,11 @@ def add_item(self, item: ViewItem) -> te.Self: ItemHandler The item handler the item was added to. """ - if not isinstance(item, NavItem): - raise TypeError(f"Expected type 'NavItem' for parameter item, not '{type(item).__name__}'.") - return super().add_item(item) + def remove_item(self, item: NavItem) -> te.Self: # pyright: ignore reportIncompatibleMethodOverride + return super().remove_item(item) + def _get_page_payload( self, page: str | hikari.Embed | t.Sequence[hikari.Embed] | Page ) -> t.MutableMapping[str, t.Any]: diff --git a/miru/internal/types.py b/miru/internal/types.py index 525006eb..769e142b 100644 --- a/miru/internal/types.py +++ b/miru/internal/types.py @@ -6,7 +6,7 @@ import hikari from miru.abc.context import Context - from miru.abc.item import Item, ViewItem + from miru.abc.item import InteractiveViewItem, Item from miru.abc.item_handler import ItemHandler from miru.view import View @@ -14,7 +14,7 @@ AppT = t.TypeVar("AppT", bound="hikari.RESTAware") BuilderT = t.TypeVar("BuilderT", bound="hikari.api.ComponentBuilder") ViewT = t.TypeVar("ViewT", bound="View") -ViewItemT = t.TypeVar("ViewItemT", bound="ViewItem") +ViewItemT = t.TypeVar("ViewItemT", bound="InteractiveViewItem") HandlerT = t.TypeVar("HandlerT", bound="ItemHandler[t.Any, t.Any, t.Any, t.Any, t.Any]") ContextT = t.TypeVar("ContextT", bound="Context[t.Any]") ItemT = t.TypeVar("ItemT", bound="Item[t.Any, t.Any, t.Any]") @@ -28,3 +28,4 @@ ModalResponseBuildersT: t.TypeAlias = "hikari.api.InteractionMessageBuilder | hikari.api.InteractionDeferredBuilder" UnhandledModalInterHookT: t.TypeAlias = "t.Callable[[hikari.ModalInteraction], t.Coroutine[t.Any, t.Any, None]]" UnhandledCompInterHookT: t.TypeAlias = "t.Callable[[hikari.ComponentInteraction], t.Coroutine[t.Any, t.Any, None]]" +InteractiveButtonStylesT: t.TypeAlias = "t.Literal[hikari.ButtonStyle.PRIMARY, hikari.ButtonStyle.SECONDARY, hikari.ButtonStyle.SUCCESS, hikari.ButtonStyle.DANGER]" diff --git a/miru/view.py b/miru/view.py index b50ef47b..386994a4 100644 --- a/miru/view.py +++ b/miru/view.py @@ -9,9 +9,9 @@ import hikari -from miru.abc.item import DecoratedItem, ViewItem +from miru.abc.item import DecoratedItem, InteractiveViewItem, ViewItem from miru.abc.item_handler import ItemHandler -from miru.button import Button +from miru.button import Button, LinkButton from miru.context.view import AutodeferOptions, ViewContext from miru.exceptions import HandlerFullError from miru.internal.types import ResponseBuildersT @@ -22,6 +22,7 @@ import typing_extensions as te + from miru.abc.select import SelectBase from miru.client import Client __all__ = ("View",) @@ -31,15 +32,14 @@ ViewT = t.TypeVar("ViewT", bound="View") -_COMPONENT_VIEW_ITEM_MAPPING: t.Mapping[hikari.ComponentType, type[ViewItem]] = { - hikari.ComponentType.BUTTON: Button, +_SELECT_VIEW_ITEM_MAPPING: t.Mapping[hikari.ComponentType, type[SelectBase]] = { hikari.ComponentType.TEXT_SELECT_MENU: TextSelect, hikari.ComponentType.CHANNEL_SELECT_MENU: ChannelSelect, hikari.ComponentType.ROLE_SELECT_MENU: RoleSelect, hikari.ComponentType.USER_SELECT_MENU: UserSelect, hikari.ComponentType.MENTIONABLE_SELECT_MENU: MentionableSelect, } -"""A mapping of all message component types to their respective item classes.""" +"""A mapping of all select types to their respective item classes.""" class View( @@ -66,12 +66,12 @@ class View( """ _view_children: t.ClassVar[ - t.MutableSequence[DecoratedItem[te.Self, ViewItem]] + t.MutableSequence[DecoratedItem[te.Self, InteractiveViewItem]] ] = [] # Decorated callbacks that need to be turned into items def __init_subclass__(cls) -> None: """Get decorated callbacks.""" - children: t.MutableSequence[DecoratedItem[te.Self, ViewItem]] = [] + children: t.MutableSequence[DecoratedItem[te.Self, InteractiveViewItem]] = [] for base_cls in reversed(cls.mro()): for value in base_cls.__dict__.values(): if isinstance(value, DecoratedItem): @@ -166,7 +166,11 @@ def from_message( for component in action_row.components: if not isinstance(component.type, hikari.ComponentType): continue # Unrecognized component types are ignored - comp_cls = _COMPONENT_VIEW_ITEM_MAPPING[component.type] + + if component.type is hikari.ComponentType.BUTTON: + comp_cls = LinkButton if getattr(component, "url", None) else Button + else: + comp_cls = _SELECT_VIEW_ITEM_MAPPING[component.type] view.add_item(comp_cls._from_component(component, row)) return view @@ -265,7 +269,7 @@ async def view_check(self, context: ViewContext, /) -> bool: return True async def on_error( - self, error: Exception, item: ViewItem | None = None, context: ViewContext | None = None, / + self, error: Exception, item: InteractiveViewItem | None = None, context: ViewContext | None = None, / ) -> None: """Called when an error occurs in a callback function or the built-in timeout function. Override for custom error-handling logic. @@ -286,7 +290,7 @@ async def on_error( traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) - async def _handle_callback(self, item: ViewItem, context: ViewContext) -> None: + async def _handle_callback(self, item: InteractiveViewItem, context: ViewContext) -> None: """Handle the callback of a view item. Separate task in case the view is stopped in the callback.""" try: if not self._message or (self._message.id == context.message.id): @@ -315,6 +319,8 @@ async def _invoke(self, interaction: hikari.ComponentInteraction) -> asyncio.Fut logger.debug(f"View received interaction for unknown custom_id '{interaction.custom_id}', ignoring.") return + assert isinstance(item, InteractiveViewItem) + self._reset_timeout() context = ViewContext(self, self.client, interaction) @@ -358,7 +364,7 @@ def _client_start_hook(self, client: Client) -> None: return # Optimize URL-button-only views by not adding to listener - if all((isinstance(item, Button) and item.url is not None) for item in self.children): + if all(isinstance(item, LinkButton) for item in self.children): logger.warning( f"View '{type(self).__name__}' only contains link buttons. Ignoring '{type(client).__name__}.start_view()' call." ) diff --git a/tests/miru/test_button.py b/tests/miru/test_button.py index 3c15f20a..15f6c6aa 100644 --- a/tests/miru/test_button.py +++ b/tests/miru/test_button.py @@ -7,12 +7,6 @@ client = miru.Client(bot) -def test_custom_id_and_url() -> None: - """Test that both custom_id and url cannot be provided.""" - with pytest.raises(TypeError): - miru.Button(custom_id="test", url="https://google.com") - - def test_label_and_emoji() -> None: """Test that both label and emoji cannot be empty.""" row = hikari.impl.MessageActionRowBuilder() @@ -22,7 +16,7 @@ def test_label_and_emoji() -> None: def test_url_style_override() -> None: """Test that url style is overridden.""" - button = miru.Button(url="https://google.com") + button = miru.LinkButton("https://google.com") assert button.style == hikari.ButtonStyle.LINK @@ -76,7 +70,27 @@ def test_from_hikari() -> None: assert button.custom_id == "test" assert button.style == hikari.ButtonStyle.PRIMARY assert button.disabled is True - assert button.url is None + + +def test_url_from_hikari() -> None: + """Test that the button is built correctly from a hikari component.""" + button = miru.LinkButton._from_component( + hikari.ButtonComponent( + type=hikari.ComponentType.BUTTON, + style=hikari.ButtonStyle.LINK, + is_disabled=True, + label="test", + url="https://google.com", + emoji=None, + custom_id=None, + ) + ) + + assert button.label == "test" + assert button.url == "https://google.com" + assert button.style == hikari.ButtonStyle.LINK + assert button.disabled is True + assert button.emoji is None def test_button_label_length() -> None: