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
Lunch today: not decided
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
Reviewer: mika
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
Summer update
Likes: 0
What changed
_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
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
Ship it?
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()