Skip to content

Selects, sections, galleries

A message can hold more than text and buttons: menus to pick from, an image or a button beside the text, galleries and files. Each is one more component for render() to return, and risa.ui.nodes lists them all with their options.

Picking from a list

A ui.TextSelect fills a ui.Row by itself. ui.SelectOption("Ramen", "ramen") shows Ramen and hands you ramen, and without the second argument the value is the label.

@risa.register(name="lunch")
class Lunch(risa.View):
    choice: str = "not decided"

    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay(f"Lunch today: **{self.choice}**"),
            ui.Row(
                ui.TextSelect(
                    self.pick,
                    ui.SelectOption("Ramen", "ramen"),
                    ui.SelectOption("Curry", "curry"),
                    placeholder="Pick one",
                ),
            ),
        )

In Discord

Risa BotAPPToday at 12:00

Lunch today: not decided

Pick one

A pick routes to a handler as a click does, and ctx.values holds the value of each option the user picked. A menu takes one pick by default, and min_values and max_values change that.

@risa.handler
async def pick(self, ctx: risa.ComponentContext) -> None:
    self.choice = ctx.values[0]
    await ctx.rerender()

Picking a user, a role or a channel

Four more menus list what Discord already knows, so they take no options.

@risa.register(name="reviewer")
class Reviewer(risa.View):
    reviewer: str = "nobody"

    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay(f"Reviewer: **{self.reviewer}**"),
            ui.Row(ui.UserSelect(self.pick, placeholder="Pick a reviewer")),
        )

ctx.values holds the ids of the picks, as strings, and ctx.resolved holds the objects, keyed by hikari.Snowflake. ctx.resolved can be None, so the handler checks it first.

@risa.handler
async def pick(self, ctx: risa.ComponentContext) -> None:
    resolved = ctx.resolved
    if resolved is not None:
        user_id = hikari.Snowflake(ctx.values[0])
        self.reviewer = resolved.users[user_id].username
    await ctx.rerender()

After a pick

Risa BotAPPToday at 12:00

Reviewer: mika

Pick a reviewer

Discord's documentation lists what each menu sends:

Menu ctx.values ctx.resolved
ui.TextSelect The value of each pick None
ui.UserSelect User ids .users, and .members in a server
ui.RoleSelect Role ids .roles
ui.MentionableSelect User and role ids .users, .roles, and .members in a server
ui.ChannelSelect Channel ids .channels
Full file
"""Select menus: `ctx.values` holds the picks, and `ctx.resolved` the objects."""

from __future__ import annotations

import os

import hikari

import risa
from risa import ui


@risa.register(name="lunch")
class Lunch(risa.View):
    choice: str = "not decided"

    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay(f"Lunch today: **{self.choice}**"),
            ui.Row(
                ui.TextSelect(
                    self.pick,
                    ui.SelectOption("Ramen", "ramen"),
                    ui.SelectOption("Curry", "curry"),
                    placeholder="Pick one",
                ),
            ),
        )

    @risa.handler
    async def pick(self, ctx: risa.ComponentContext) -> None:
        self.choice = ctx.values[0]
        await ctx.rerender()


@risa.register(name="reviewer")
class Reviewer(risa.View):
    reviewer: str = "nobody"

    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay(f"Reviewer: **{self.reviewer}**"),
            ui.Row(ui.UserSelect(self.pick, placeholder="Pick a reviewer")),
        )

    @risa.handler
    async def pick(self, ctx: risa.ComponentContext) -> None:
        resolved = ctx.resolved
        if resolved is not None:
            user_id = hikari.Snowflake(ctx.values[0])
            self.reviewer = resolved.users[user_id].username
        await ctx.rerender()


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )
    client = risa.client_from_app(bot)
    client.add_view(Lunch)
    client.add_view(Reviewer)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!lunch":
            rendered = await client.build(Lunch())
            await rendered.send_to(bot.rest, event.channel_id)
        elif event.content == "!reviewer":
            rendered = await client.build(Reviewer())
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()

Text with something beside it

A ui.Section draws text with one accessory beside it: a thumbnail, a button or a link.

@risa.register(name="release")
class Release(risa.View):
    likes: int = 0

    def render(self) -> ui.Layout:
        art = ui.Thumbnail(_ART, description="The mascot")
        like = ui.Button(self.like, label="Like")
        notes = ui.LinkButton(_NOTES, label="Read the notes")
        return ui.Container(
            ui.Section(ui.TextDisplay("## Summer update"), accessory=art),
            ui.Section(ui.TextDisplay(f"Likes: {self.likes}"), accessory=like),
            ui.Section(ui.TextDisplay("What changed"), accessory=notes),
        )

In Discord

Risa BotAPPToday at 12:00

Summer update

Likes: 0

Like

What changed

Read the notes

_ART and _NOTES are URLs from the top of the file. ui.Thumbnail is a small image, and description is its alternative text. The previews on this page draw a plain box where Discord draws an image.

A ui.Button as an accessory routes like any other button, and it holds the state as one in a row would.

@risa.handler
async def like(self, ctx: risa.ComponentContext) -> None:
    self.likes += 1
    await ctx.rerender()

ui.LinkButton opens a URL, and ui.PremiumButton(sku_id) offers one of your app's SKUs for purchase. Discord's documentation says neither sends your bot an interaction and neither can have a custom_id, so they take no handler and cannot hold state.

Full file
"""Sections: text with a thumbnail, a button or a link beside it."""

from __future__ import annotations

import os

import hikari

import risa
from risa import ui

_IMAGES = "https://hikari-risa.rayakame.dev/main/assets/images"
_ART = f"{_IMAGES}/mascot-sticker.webp"
_NOTES = "https://github.com/rayakame/hikari-risa"


@risa.register(name="release")
class Release(risa.View):
    likes: int = 0

    def render(self) -> ui.Layout:
        art = ui.Thumbnail(_ART, description="The mascot")
        like = ui.Button(self.like, label="Like")
        notes = ui.LinkButton(_NOTES, label="Read the notes")
        return ui.Container(
            ui.Section(ui.TextDisplay("## Summer update"), accessory=art),
            ui.Section(ui.TextDisplay(f"Likes: {self.likes}"), accessory=like),
            ui.Section(ui.TextDisplay("What changed"), accessory=notes),
        )

    @risa.handler
    async def like(self, ctx: risa.ComponentContext) -> None:
        self.likes += 1
        await ctx.rerender()


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )
    client = risa.client_from_app(bot)
    client.add_view(Release)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!release":
            rendered = await client.build(Release())
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()

Galleries, separators and files

ui.MediaGallery lays out images and videos in a grid, ui.Separator leaves space with a line in it, and ui.File attaches a file.

@risa.register(name="meetup")
class Meetup(risa.View):
    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay("## Photos from the meetup"),
            ui.MediaGallery(
                ui.MediaGalleryItem(_BANNER, description="The banner"),
                ui.MediaGalleryItem(_ART, description="The mascot"),
            ),
            ui.Separator(),
            ui.TextDisplay("The notes are attached."),
            ui.File(hikari.Bytes(b"Bring a friend next time.", "notes.txt")),
        )

In Discord

Risa BotAPPToday at 12:00

Photos from the meetup

The notes are attached.

ui.MediaGalleryItem takes a URL, as these do, or a file.

ui.File takes the file itself: a path, or data named through hikari.Bytes as here. Discord's documentation allows only attachment:// references for it, which hikari writes for you, so a URL will not do.

A rerender uploads the file again, because render() draws it again.

Full file
"""A gallery, a separator and an attached file."""

from __future__ import annotations

import os

import hikari

import risa
from risa import ui

_IMAGES = "https://hikari-risa.rayakame.dev/main/assets/images"
_BANNER = f"{_IMAGES}/banner.webp"
_ART = f"{_IMAGES}/mascot-sticker.webp"


@risa.register(name="meetup")
class Meetup(risa.View):
    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay("## Photos from the meetup"),
            ui.MediaGallery(
                ui.MediaGalleryItem(_BANNER, description="The banner"),
                ui.MediaGalleryItem(_ART, description="The mascot"),
            ),
            ui.Separator(),
            ui.TextDisplay("The notes are attached."),
            ui.File(hikari.Bytes(b"Bring a friend next time.", "notes.txt")),
        )


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )
    client = risa.client_from_app(bot)
    client.add_view(Meetup)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!meetup":
            rendered = await client.build(Meetup())
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()

Rows alone

A tree of nothing but rows is what older bots send, and risa builds it from the same components. hikari does not set the IS_COMPONENTS_V2 flag for a row, so this shape reaches Discord as a V1 message, the kind from before Components V2. It is the only shape that can carry content beside it.

@risa.register(name="yes-no")
class YesNo(risa.View):
    def render(self) -> ui.Layout:
        return ui.Row(
            ui.Button(self.yes, label="Yes"),
            ui.Button(self.no, label="No", style=hikari.ButtonStyle.SECONDARY),
        )

    @risa.handler
    async def yes(self, ctx: risa.ComponentContext) -> None:
        await ctx.respond("Shipping it.", ephemeral=True)

    @risa.handler
    async def no(self, ctx: risa.ComponentContext) -> None:
        await ctx.respond("Holding it back.", ephemeral=True)

In Discord

Risa BotAPPToday at 12:00

Ship it?

YesNo

send_to refuses content, so an event calls hikari directly.

@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
    if event.content == "!shipit":
        rendered = await client.build(YesNo())
        await bot.rest.create_message(
            event.channel_id,
            "Ship it?",
            **rendered,
        )

In a lightbulb command, or in a risa handler that takes client: risa.Client as a parameter after ctx, it reads await ctx.respond("Ship it?", **await client.build(YesNo())).

Full file
"""Rows and nothing else: the one shape that can carry `content` beside it."""

from __future__ import annotations

import os

import hikari

import risa
from risa import ui


@risa.register(name="yes-no")
class YesNo(risa.View):
    def render(self) -> ui.Layout:
        return ui.Row(
            ui.Button(self.yes, label="Yes"),
            ui.Button(self.no, label="No", style=hikari.ButtonStyle.SECONDARY),
        )

    @risa.handler
    async def yes(self, ctx: risa.ComponentContext) -> None:
        await ctx.respond("Shipping it.", ephemeral=True)

    @risa.handler
    async def no(self, ctx: risa.ComponentContext) -> None:
        await ctx.respond("Holding it back.", ephemeral=True)


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )
    client = risa.client_from_app(bot)
    client.add_view(YesNo)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!shipit":
            rendered = await client.build(YesNo())
            await bot.rest.create_message(
                event.channel_id,
                "Ship it?",
                **rendered,
            )

    bot.run()


if __name__ == "__main__":
    main()